diff --git a/.vscode/launch.json b/.vscode/launch.json index 14abf63..9ae82f1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,7 +7,7 @@ "type": "dart", "program": "lib/main.dart", "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" ] }, diff --git a/lib/core/config.dart b/lib/core/config.dart index d3a6341..901693b 100644 --- a/lib/core/config.dart +++ b/lib/core/config.dart @@ -8,7 +8,7 @@ class AppConfig { /// با تغییر شبکه/IP مک، مقدار dart-define یا همین پیش‌فرض را عوض کنید. static const String baseUrl = String.fromEnvironment( 'BASE_URL', - defaultValue: 'http://192.168.8.210:8080', + defaultValue: 'http://192.168.1.105:8080', ); static String get apiUrl => '$baseUrl/api'; diff --git a/lib/core/locator/locator.dart b/lib/core/locator/locator.dart index 0a2b043..5eda19e 100644 --- a/lib/core/locator/locator.dart +++ b/lib/core/locator/locator.dart @@ -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/presentation/bloc/leaderboard_bloc.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/domain/repository/shop_repository.dart'; import '../../feature/shop/domain/use_cases/ad_reward_usecase.dart'; @@ -61,6 +62,7 @@ Future setupLocator() async { locator.registerSingleton(AuthApiProvider()); locator.registerSingleton(WalletApiProvider()); locator.registerSingleton(ShopApiProvider()); + locator.registerSingleton(CarpetApiProvider()); locator.registerSingleton(ProfileApiProvider()); locator.registerSingleton(RankedApiProvider()); locator.registerSingleton(ChatApiProvider()); diff --git a/lib/core/network/card_images.dart b/lib/core/network/card_images.dart index 29f962b..6e64609 100644 --- a/lib/core/network/card_images.dart +++ b/lib/core/network/card_images.dart @@ -57,6 +57,22 @@ class CardImages { } } + /// تصویرِ فرش (برای سطحِ میز) از backend: `/carpets/.jpg`. کش‌شده. + static Future carpet(String id) { + return _cache.putIfAbsent('carpet/$id', () async { + try { + final res = await _dio + .get>('${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 _decode(Uint8List bytes) async { final codec = await ui.instantiateImageCodec(bytes); final frame = await codec.getNextFrame(); diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 0f983cc..1bd2cba 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -53,11 +53,17 @@ class AppTheme { final base = ThemeData.dark(useMaterial3: true); return base.copyWith( scaffoldBackgroundColor: AppColors.bg, - primaryColor: AppColors.accent, + primaryColor: AppColors.lapis, + canvasColor: AppColors.bg, + cardColor: AppColors.panel, + dividerColor: AppColors.goldFaint, colorScheme: base.colorScheme.copyWith( primary: AppColors.gold, - secondary: AppColors.accent, + onPrimary: AppColors.lapisInk, + secondary: AppColors.lapis, + tertiary: AppColors.accent, surface: AppColors.panel, + onSurface: AppColors.text, ), textTheme: base.textTheme.apply( fontFamily: fontFamily, @@ -69,6 +75,36 @@ class AppTheme { foregroundColor: AppColors.gold, 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( filled: true, fillColor: AppColors.bgDark.withValues(alpha: 0.6), diff --git a/lib/core/widgets/game_ui.dart b/lib/core/widgets/game_ui.dart index b75b129..2646536 100644 --- a/lib/core/widgets/game_ui.dart +++ b/lib/core/widgets/game_ui.dart @@ -1,8 +1,41 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import '../service/app_sounds.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): /// پس‌زمینه‌ی گرادیانی، پنل براق، دکمه‌ی جواهرگون، و عنوانِ درخشان. diff --git a/lib/feature/chat/presentation/widgets/chat_panel.dart b/lib/feature/chat/presentation/widgets/chat_panel.dart index ecbb73b..9094242 100644 --- a/lib/feature/chat/presentation/widgets/chat_panel.dart +++ b/lib/feature/chat/presentation/widgets/chat_panel.dart @@ -50,7 +50,9 @@ class _ChatPanelState extends State { _future = _api.getChatPacks(); } - void _reload() => setState(() => _future = _api.getChatPacks()); + void _reload() => setState(() { + _future = _api.getChatPacks(); + }); Future _buy(ChatPack p) async { setState(() => _busyPack = p.id); diff --git a/lib/feature/game/presentation/screen/game_screen.dart b/lib/feature/game/presentation/screen/game_screen.dart index 5d57918..13e7194 100644 --- a/lib/feature/game/presentation/screen/game_screen.dart +++ b/lib/feature/game/presentation/screen/game_screen.dart @@ -31,6 +31,7 @@ class GameScreen extends StatefulWidget { class _GameScreenState extends State { late final HokmGame _game; + String _carpet = 'classic'; // فرشِ انتخابی (برای پس‌زمینه‌ی پشتِ صحنه) Timer? _introTimer; bool _introHidden = false; int _countdown = 0; // شمارشِ معکوسِ شروعِ بازی پس از یافتنِ حریفان (۳…۲…۱) @@ -40,9 +41,11 @@ class _GameScreenState extends State { super.initState(); final ws = context.read().state.walletStatus; final skin = ws is WalletLoaded ? ws.wallet.selectedCard : 'simple'; + _carpet = ws is WalletLoaded ? ws.wallet.selectedCarpet : 'classic'; _game = HokmGame( context.read(), skin: skin.isEmpty ? 'simple' : skin, + carpet: _carpet.isEmpty ? 'classic' : _carpet, ); } @@ -114,6 +117,7 @@ class _GameScreenState extends State { } return Stack( children: [ + // فرش به‌عنوانِ سطحِ میز داخلِ صحنه‌ی Flame (Felt) کشیده می‌شود. GameWidget(game: _game), if (!_showSearch(state) && state.state != null) TableHud( diff --git a/lib/feature/game/presentation/screen/private_entry_screen.dart b/lib/feature/game/presentation/screen/private_entry_screen.dart index 2e29a8d..55bd635 100644 --- a/lib/feature/game/presentation/screen/private_entry_screen.dart +++ b/lib/feature/game/presentation/screen/private_entry_screen.dart @@ -65,25 +65,11 @@ class _PrivateEntryScreenState extends State { final canCreate = unlimited || remaining > 0; return Column( children: [ - Align( + const 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), - ), - ), + padding: EdgeInsets.all(10), + child: AppBackButton(), ), ), Expanded( diff --git a/lib/feature/game/presentation/screen/private_table_screen.dart b/lib/feature/game/presentation/screen/private_table_screen.dart index 8c3df0e..e8e7c79 100644 --- a/lib/feature/game/presentation/screen/private_table_screen.dart +++ b/lib/feature/game/presentation/screen/private_table_screen.dart @@ -20,18 +20,22 @@ class PrivateTableScreen extends StatelessWidget { @override Widget build(BuildContext context) { return BlocConsumer( - listenWhen: (a, b) => - (a.notice != b.notice && b.notice != null) || - (!a.tableClosed && b.tableClosed), + 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( + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( content: Text(state.notice!), - duration: const Duration(seconds: 2))); + duration: const Duration(seconds: 2), + ), + ); context.read().clearNotice(); } }, @@ -81,11 +85,14 @@ class _LobbyViewState extends State<_LobbyView> { children: [ _topBar(context, lobby), Expanded( - child: connecting - ? const Center( - child: CircularProgressIndicator( - color: AppColors.gold)) - : _content(context, lobby), + child: + connecting + ? const Center( + child: CircularProgressIndicator( + color: AppColors.gold, + ), + ) + : _content(context, lobby), ), ], ), @@ -102,18 +109,18 @@ class _LobbyViewState extends State<_LobbyView> { // نوار بالا: بازگشت (چپ) و اشتراک‌گذاریِ کد (راست). Widget _topBar(BuildContext context, TableLobby? lobby) { Widget btn(IconData icon, VoidCallback onTap) => GestureDetector( - onTap: onTap, - 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: Icon(icon, color: AppColors.gold), - ), - ); + onTap: onTap, + 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: Icon(icon, color: AppColors.gold), + ), + ); return Padding( padding: const EdgeInsets.all(10), child: Directionality( @@ -121,13 +128,16 @@ class _LobbyViewState extends State<_LobbyView> { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - btn(Icons.arrow_back, _leave), if (lobby != null) btn(Icons.share, () { Clipboard.setData(ClipboardData(text: lobby.code)); - ScaffoldMessenger.of(context).showSnackBar(const SnackBar( - content: Text('کد میز کپی شد؛ برای دوستانت بفرست'))); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('کد میز کپی شد؛ برای دوستانت بفرست'), + ), + ); }), + btn(Icons.arrow_forward, _leave), ], ), ), @@ -160,7 +170,9 @@ class _LobbyViewState extends State<_LobbyView> { children: [ Padding( padding: const EdgeInsets.symmetric( - horizontal: 28, vertical: 36), + horizontal: 28, + vertical: 36, + ), child: Container( decoration: BoxDecoration( gradient: const RadialGradient( @@ -168,29 +180,42 @@ class _LobbyViewState extends State<_LobbyView> { radius: 0.9, ), borderRadius: BorderRadius.circular(160), - border: Border.all(color: const Color(0xFF8A5A12), width: 6), - ), - child: const Center( - child: GlowText('سلطان حکم', size: 18), + border: Border.all( + color: const Color(0xFF8A5A12), + width: 6, + ), ), + child: const Center(child: GlowText('سلطان حکم', size: 18)), ), ), Align(alignment: Alignment.topCenter, child: _seat(top, false)), - Align(alignment: Alignment.centerLeft, child: _seat(left, false)), Align( - alignment: Alignment.centerRight, child: _seat(right, false)), - Align(alignment: Alignment.bottomCenter, child: _seat(me, true)), + alignment: Alignment.centerLeft, + child: _seat(left, false), + ), + Align( + alignment: Alignment.centerRight, + child: _seat(right, false), + ), + Align( + alignment: Alignment.bottomCenter, + child: _seat(me, true), + ), // چرخشِ جایگاه‌ها (فقط میزبان) — تغییرِ تیم‌بندی. if (lobby.isHost) ...[ Align( alignment: const Alignment(-1, -0.35), - child: _rotateBtn(Icons.rotate_left, - () => context.read().rotateTable(-1)), + child: _rotateBtn( + Icons.rotate_left, + () => context.read().rotateTable(-1), + ), ), Align( alignment: const Alignment(1, -0.35), - child: _rotateBtn(Icons.rotate_right, - () => context.read().rotateTable(1)), + child: _rotateBtn( + Icons.rotate_right, + () => context.read().rotateTable(1), + ), ), ], ], @@ -215,12 +240,16 @@ class _LobbyViewState extends State<_LobbyView> { onTap: () => context.read().startTable(_hands), ) else - const Text('در انتظار شروع توسط میزبان…', - style: TextStyle(color: AppColors.gold, fontSize: 15)), + const Text( + 'در انتظار شروع توسط میزبان…', + style: TextStyle(color: AppColors.gold, fontSize: 15), + ), const SizedBox(height: 6), if (lobby.isHost) - const Text('جای‌های خالی با ربات پر می‌شوند', - style: TextStyle(color: Colors.white54, fontSize: 12)), + const Text( + 'جای‌های خالی با ربات پر می‌شوند', + style: TextStyle(color: Colors.white54, fontSize: 12), + ), const SizedBox(height: 20), ], ), @@ -244,15 +273,20 @@ class _LobbyViewState extends State<_LobbyView> { color: AppColors.bgDark, borderRadius: BorderRadius.circular(12), border: Border.all( - color: isYou ? AppColors.gold : AppColors.goldDark, - width: 2), + color: isYou ? AppColors.gold : AppColors.goldDark, + width: 2, + ), ), - child: empty - ? const Icon(Icons.person_add_alt_1, color: Colors.white24) - : ClipRRect( - borderRadius: BorderRadius.circular(9), - child: RandomAvatar(p.name), - ), + child: + empty + ? const Icon( + Icons.person_add_alt_1, + color: Colors.white24, + ) + : ClipRRect( + borderRadius: BorderRadius.circular(9), + child: RandomAvatar(p.name), + ), ), if (!empty) const Positioned( @@ -274,8 +308,10 @@ class _LobbyViewState extends State<_LobbyView> { ), ), if (p?.host == true) - const Text('میزبان', - style: TextStyle(color: AppColors.gold, fontSize: 10)), + const Text( + 'میزبان', + style: TextStyle(color: AppColors.gold, fontSize: 10), + ), ], ); } @@ -314,14 +350,17 @@ class _LobbyViewState extends State<_LobbyView> { alignment: Alignment.center, decoration: BoxDecoration( shape: BoxShape.circle, - gradient: selected - ? const LinearGradient( - colors: [AppColors.gold, AppColors.goldDark]) - : null, + gradient: + selected + ? const LinearGradient( + colors: [AppColors.gold, AppColors.goldDark], + ) + : null, color: selected ? null : AppColors.panel, border: Border.all( - color: selected ? AppColors.gold : AppColors.goldDark, - width: 2), + color: selected ? AppColors.gold : AppColors.goldDark, + width: 2, + ), ), child: Text( fa[h]!, @@ -377,8 +416,9 @@ class _CountdownOverlayState extends State<_CountdownOverlay> { 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), + builder: + (context, scale, child) => + Transform.scale(scale: scale, child: child), child: GlowText(_n > 0 ? '$_n' : 'شروع!', size: 96), ), ); diff --git a/lib/feature/game/presentation/screen/tier_list_screen.dart b/lib/feature/game/presentation/screen/tier_list_screen.dart index e944841..4be0933 100644 --- a/lib/feature/game/presentation/screen/tier_list_screen.dart +++ b/lib/feature/game/presentation/screen/tier_list_screen.dart @@ -20,7 +20,7 @@ class TierListScreen extends StatelessWidget { Padding( padding: const EdgeInsets.all(12), child: Row(children: [ - _BackButton(onTap: () => context.pop()), + const AppBackButton(), const Spacer(), const GlowText('انتخاب میز', size: 26), 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 { final TableTier tier; final int index; diff --git a/lib/feature/game/presentation/widgets/flame/hokm_game.dart b/lib/feature/game/presentation/widgets/flame/hokm_game.dart index 87ce209..8b6b727 100644 --- a/lib/feature/game/presentation/widgets/flame/hokm_game.dart +++ b/lib/feature/game/presentation/widgets/flame/hokm_game.dart @@ -69,14 +69,18 @@ class HokmGame extends FlameGame { double _hbAccum = 0; // انباشتِ زمان برای تکرارِ منظمِ ضربان تا پایانِ نوبت bool _wasMyTurn = false; - HokmGame(this.cubit, {this.skin = 'simple'}); + /// فرشِ انتخابی؛ 'classic' یعنی سطحِ میزِ ترسیمی. فرشِ تصویری به‌عنوانِ سطحِ + /// میز (بریده در شکلِ میز) روی زمینِ لاجوردی کشیده می‌شود. + final String carpet; + + HokmGame(this.cubit, {this.skin = 'simple', this.carpet = 'classic'}); @override Color backgroundColor() => AppColors.lapisNight; @override Future onLoad() async { - add(Felt()); + add(Felt(carpet: carpet)); _sub = cubit.stream.listen((ui) { if (ui.state == null) return; final s = ui.state!; diff --git a/lib/feature/game/presentation/widgets/flame/table_pieces.dart b/lib/feature/game/presentation/widgets/flame/table_pieces.dart index db97b8b..2eda58a 100644 --- a/lib/feature/game/presentation/widgets/flame/table_pieces.dart +++ b/lib/feature/game/presentation/widgets/flame/table_pieces.dart @@ -3,22 +3,39 @@ import 'dart:math' as math; import 'package:flame/components.dart'; import 'package:flutter/material.dart'; +import '../../../../../core/network/card_images.dart'; import '../../../../../core/theme/app_theme.dart'; // رنگ‌های مشترکِ میز (منبعِ واحد در AppColors). const _gold = AppColors.gold; const _goldDark = AppColors.goldDark; -/// میز: زمینِ سبزِ نمدی در کلِ صفحه + یک «میزِ» مرکزیِ سرمه‌ای با قابِ طلاییِ -/// دولایه (مطابقِ تصویرِ مرجع). کارت‌های زمین داخلِ ناحیه‌ی سرمه‌ای انداخته می‌شوند -/// و پشت‌کارتِ حریفان روی نمدِ سبزِ دورِ آن می‌نشیند. +/// میز: زمینِ لاجوردیِ نمدی در کلِ صفحه + یک «میزِ» مرکزی با قابِ طلاییِ دولایه. +/// اگر فرشی انتخاب شده باشد، سطحِ میز تصویرِ فرش است (بریده‌شده در شکلِ میز)؛ +/// وگرنه سطحِ سرمه‌ایِ ترسیمی. کارت‌ها داخلِ همین ناحیه انداخته می‌شوند. class Felt extends PositionComponent with HasGameReference { + /// شناسه‌ی فرشِ انتخابی؛ 'classic' یعنی سطحِ ترسیمی (بدون تصویر). + final String carpet; + Felt({this.carpet = 'classic'}); + + Sprite? _carpetSprite; + + bool get _hasCarpet => carpet != 'classic'; + + @override + Future onLoad() async { + if (_hasCarpet) { + _carpetSprite = await CardImages.carpet(carpet); + } + } + @override void render(Canvas canvas) { final w = game.size.x, h = game.size.y; final center = Offset(w / 2, h / 2); - // ۱) زمینِ لاجوردیِ نمدی (کلِ صفحه) با وینیتِ شعاعی برای عمق. + // ۱) زمینِ لاجوردیِ نمدی (کلِ صفحه) با وینیتِ شعاعی برای عمق. همیشه کشیده + // می‌شود تا زیرِ میز/فرش یکدست باشد و لبه‌های فرش با پس‌زمینه جور شود. canvas.drawRect( Rect.fromLTWH(0, 0, w, h), Paint() @@ -30,7 +47,7 @@ class Felt extends PositionComponent with HasGameReference { ).createShader(Rect.fromLTWH(0, 0, w, h)), ); - // ۲) میزِ مرکزیِ سرمه‌ای با قابِ طلایی. + // ۲) میزِ مرکزی با قابِ طلایی. final rect = Rect.fromCenter( center: center, width: w * 0.72, height: h * 0.46); final radius = Radius.circular(h * 0.05); @@ -55,17 +72,32 @@ class Felt extends PositionComponent with HasGameReference { ).createShader(rect), ); - // سطحِ سرمه‌ایِ میز با گرادیانِ شعاعی (مرکز روشن‌تر). - 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), - ); + // سطحِ میز: تصویرِ فرش (بریده در شکلِ میز) یا سطحِ سرمه‌ایِ ترسیمی. + final sprite = _carpetSprite; + if (_hasCarpet && sprite != null) { + canvas.save(); + canvas.clipRRect(table); + // فرش را به‌صورتِ cover داخلِ مستطیلِ میز می‌کشیم. + sprite.render( + canvas, + position: Vector2(rect.left, rect.top), + 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( diff --git a/lib/feature/profile/presentation/screen/profile_screen.dart b/lib/feature/profile/presentation/screen/profile_screen.dart index 2c66d6f..fd103fb 100644 --- a/lib/feature/profile/presentation/screen/profile_screen.dart +++ b/lib/feature/profile/presentation/screen/profile_screen.dart @@ -92,10 +92,7 @@ class ProfileScreen extends StatelessWidget { children: [ Row( children: [ - IconButton( - onPressed: () => context.pop(), - icon: const Icon(Icons.arrow_back, color: AppColors.gold), - ), + const AppBackButton(), const Spacer(), const GlowText('پروفایل', size: 24), const Spacer(), diff --git a/lib/feature/ranked/presentation/screen/leaderboard_screen.dart b/lib/feature/ranked/presentation/screen/leaderboard_screen.dart index 522091e..d64d64c 100644 --- a/lib/feature/ranked/presentation/screen/leaderboard_screen.dart +++ b/lib/feature/ranked/presentation/screen/leaderboard_screen.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; import 'package:random_avatar/random_avatar.dart'; import '../../../../core/theme/app_theme.dart'; @@ -21,10 +20,7 @@ class LeaderboardScreen extends StatelessWidget { child: Column( children: [ Row(children: [ - IconButton( - onPressed: () => context.pop(), - icon: const Icon(Icons.arrow_back, color: AppColors.gold), - ), + const AppBackButton(), const Spacer(), const GlowText('رتبه‌بندی فصل', size: 22), const Spacer(), diff --git a/lib/feature/shop/data/data_source/remote/carpet_api_provider.dart b/lib/feature/shop/data/data_source/remote/carpet_api_provider.dart new file mode 100644 index 0000000..48477db --- /dev/null +++ b/lib/feature/shop/data/data_source/remote/carpet_api_provider.dart @@ -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(); + + Future 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.from(e as Map))) + .toList(), + selected: (res.data['selected'] ?? 'classic') as String, + ); + } + + /// خرید؛ null یعنی موفق، وگرنه پیامِ خطای فارسی. + Future buyCarpet(String id) => + _post('/shop/buy-carpet', id); + + /// انتخاب؛ null یعنی موفق، وگرنه پیامِ خطای فارسی. + Future selectCarpet(String id) => + _post('/shop/select-carpet', id); + + Future _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 'عملیات ناموفق بود'; + } + } +} diff --git a/lib/feature/shop/domain/entities/carpet.dart b/lib/feature/shop/domain/entities/carpet.dart new file mode 100644 index 0000000..2e956da --- /dev/null +++ b/lib/feature/shop/domain/entities/carpet.dart @@ -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 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 carpets; + final String selected; + const CarpetCatalog({required this.carpets, required this.selected}); +} diff --git a/lib/feature/shop/presentation/screen/shop_screen.dart b/lib/feature/shop/presentation/screen/shop_screen.dart index 6975d08..db85748 100644 --- a/lib/feature/shop/presentation/screen/shop_screen.dart +++ b/lib/feature/shop/presentation/screen/shop_screen.dart @@ -1,14 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; import '../../../../core/config.dart'; +import '../../../../core/locator/locator.dart'; import '../../../../core/theme/app_theme.dart'; import '../../../../core/widgets/game_ui.dart'; import '../../../wallet/presentation/bloc/wallet_bloc.dart'; import '../../../wallet/presentation/bloc/wallet_event.dart'; import '../../../wallet/presentation/bloc/wallet_state.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 '../bloc/shop_bloc.dart'; import '../bloc/shop_event.dart'; @@ -22,7 +24,7 @@ class ShopScreen extends StatelessWidget { @override Widget build(BuildContext context) { return DefaultTabController( - length: 5, + length: 6, child: Scaffold( backgroundColor: Colors.transparent, body: GameBackground( @@ -85,6 +87,7 @@ class ShopScreen extends StatelessWidget { _CoinsTab(packages: d.coinPackages), _TicketsTab(packages: d.ticketPackages), _CardsTab(data: d), + const _CarpetsTab(), _BoostersTab(boosters: d.boosters), _VipTab(packages: d.vipPackages), ], @@ -114,19 +117,7 @@ class ShopScreen extends StatelessWidget { padding: const EdgeInsets.all(8), child: Row( children: [ - 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), - ), - ), + const AppBackButton(), const Spacer(), BlocBuilder( builder: (context, s) { @@ -164,7 +155,7 @@ class _ShopTabs extends StatelessWidget { gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Color(0xFFC62828), Color(0xFF7B0E14)], + colors: [AppColors.panel, AppColors.lapis], ), 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: '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(); + late Future _future; + String? _busy; + + @override + void initState() { + super.initState(); + _future = _api.getCarpets(); + } + + void _reload() => setState(() { + _future = _api.getCarpets(); + }); + + Future _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().add(LoadWalletEvent()); + _reload(); + } else { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(err))); + } + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + 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 children}) { return Column( children: [ @@ -506,13 +671,16 @@ class _CardSkinArt extends StatelessWidget { child: Image.network( _url(file), fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Container( - color: const Color(0xFF0E3C73), - child: const Icon(Icons.style, color: AppColors.gold), - ), - loadingBuilder: (ctx, child, progress) => progress == null - ? child - : Container(color: const Color(0xFF0A2547)), + errorBuilder: + (_, __, ___) => Container( + color: const Color(0xFF0E3C73), + child: const Icon(Icons.style, color: AppColors.gold), + ), + loadingBuilder: + (ctx, child, progress) => + progress == null + ? child + : Container(color: const Color(0xFF0A2547)), ), ), ), diff --git a/lib/feature/shop/presentation/screen/vip_screen.dart b/lib/feature/shop/presentation/screen/vip_screen.dart index ca1a228..d31f301 100644 --- a/lib/feature/shop/presentation/screen/vip_screen.dart +++ b/lib/feature/shop/presentation/screen/vip_screen.dart @@ -1,6 +1,5 @@ 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'; @@ -60,10 +59,7 @@ class VipScreen extends StatelessWidget { return Column( children: [ Row(children: [ - IconButton( - onPressed: () => context.pop(), - icon: const Icon(Icons.arrow_back, color: AppColors.gold), - ), + const AppBackButton(), const Spacer(), const GlowText('اشتراک VIP', size: 24), const Spacer(), diff --git a/lib/feature/wallet/data/model/wallet_model.dart b/lib/feature/wallet/data/model/wallet_model.dart index 4a04a95..1e93687 100644 --- a/lib/feature/wallet/data/model/wallet_model.dart +++ b/lib/feature/wallet/data/model/wallet_model.dart @@ -12,6 +12,7 @@ class WalletModel extends WalletEntity { required super.xpForNext, required super.vip, required super.selectedCard, + required super.selectedCarpet, required super.name, required super.avatar, }); @@ -32,6 +33,7 @@ class WalletModel extends WalletEntity { xpForNext: (wallet['xp_for_next'] ?? 1) as int, vip: (wallet['vip'] ?? false) as bool, selectedCard: (wallet['selected_card'] ?? 'simple') as String, + selectedCarpet: (wallet['selected_carpet'] ?? 'classic') as String, name: (name == null || name.isEmpty) ? 'بازیکن' : name, avatar: (avatar == null || avatar.isEmpty) ? 'hakem-1' : avatar, ); diff --git a/lib/feature/wallet/domain/entities/wallet_entity.dart b/lib/feature/wallet/domain/entities/wallet_entity.dart index 1a71575..2f5a4e0 100644 --- a/lib/feature/wallet/domain/entities/wallet_entity.dart +++ b/lib/feature/wallet/domain/entities/wallet_entity.dart @@ -9,6 +9,7 @@ class WalletEntity { final int xpForNext; final bool vip; final String selectedCard; + final String selectedCarpet; final String name; final String avatar; @@ -22,6 +23,7 @@ class WalletEntity { required this.xpForNext, required this.vip, required this.selectedCard, + required this.selectedCarpet, required this.name, required this.avatar, });