diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 4f9734b..cff7e23 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index bcb3775..ce3823b 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index d755e1a..a0ee9a3 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 56cb60c..4d87be9 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 4d73837..a596c2a 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/assets/images/game_bg.jpg b/assets/images/game_bg.jpg new file mode 100644 index 0000000..aaebacd Binary files /dev/null and b/assets/images/game_bg.jpg differ diff --git a/lib/core/settings/app_settings.dart b/lib/core/settings/app_settings.dart new file mode 100644 index 0000000..5bd810f --- /dev/null +++ b/lib/core/settings/app_settings.dart @@ -0,0 +1,24 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// تنظیماتِ سراسریِ اپ (فعلاً کیفیتِ گرافیک). با ذخیره‌ی محلی پایدار می‌ماند. +class AppSettings { + AppSettings._(); + static final AppSettings I = AppSettings._(); + + final _storage = const FlutterSecureStorage(); + static const _kLowGfx = 'low_graphics'; + + /// حالتِ کم‌مصرف: افکت‌های اتمسفری (ذرات، شوک‌ویوِ بریدن، پس‌زمینه‌ی تصویری) + /// خاموش می‌شوند تا روی گوشی‌های ضعیف روان بماند. با ValueNotifier تا UI واکنش دهد. + final ValueNotifier lowGraphics = ValueNotifier(false); + + Future load() async { + lowGraphics.value = (await _storage.read(key: _kLowGfx)) == '1'; + } + + Future setLowGraphics(bool v) async { + lowGraphics.value = v; + await _storage.write(key: _kLowGfx, value: v ? '1' : '0'); + } +} diff --git a/lib/feature/game/presentation/screen/game_screen.dart b/lib/feature/game/presentation/screen/game_screen.dart index dc52279..7f059e6 100644 --- a/lib/feature/game/presentation/screen/game_screen.dart +++ b/lib/feature/game/presentation/screen/game_screen.dart @@ -90,8 +90,15 @@ class _GameScreenState extends State { listenWhen: (a, b) => (a.notice != b.notice && b.notice != null) || - (a.gameOver == null && b.gameOver != null), + (a.gameOver == null && b.gameOver != null) || + (a.handResult == null && b.handResult != null), listener: (context, state) { + // جشنِ کُت: هنگامِ دستِ کُت‌شده‌ی بُرده، ضربه‌ی لمسی. + final hr = state.handResult; + if (hr != null && hr.kot) { + final myTeam = (state.state?.yourSeat ?? 0) % 2; + if (hr.winnerTeam == myTeam) HapticFeedback.mediumImpact(); + } if (state.gameOver != null) { final won = state.gameOver!.winnerTeam == @@ -540,44 +547,77 @@ class _GameScreenState extends State { final mySeat = s.state?.yourSeat ?? 0; final myTeam = mySeat % 2; final won = r.winnerTeam == myTeam; + final kotWin = won && r.kot; // جشنِ ویژه فقط برای کُتِ بُرده + return IgnorePointer( - child: Center( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 18), - decoration: BoxDecoration( - color: Colors.black87, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.gold), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - won ? 'این دست را بردید!' : 'این دست را باختید', - style: TextStyle( - color: won ? AppColors.green : Colors.redAccent, - fontSize: 20, - fontWeight: FontWeight.bold, - ), + child: Stack( + children: [ + if (kotWin) const Positioned.fill(child: Confetti(count: 40)), + Center( + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 28, vertical: 18), + decoration: BoxDecoration( + gradient: kotWin + ? const LinearGradient( + colors: [AppColors.lapisMid, AppColors.lapisInk]) + : null, + color: kotWin ? null : Colors.black87, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: AppColors.gold, width: kotWin ? 2 : 1), + boxShadow: kotWin + ? [ + BoxShadow( + color: AppColors.gold.withValues(alpha: 0.4), + blurRadius: 20), + ] + : null, ), - if (r.kot) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - r.hakemKot - ? 'حاکم‌کُت! (${r.points} امتیاز)' - : 'کُت! (${r.points} امتیاز)', - style: const TextStyle(color: AppColors.gold), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (kotWin) + Text( + r.hakemKot ? 'حاکم‌کُت! 🔥' : 'کُت! 🔥', + style: const TextStyle( + color: AppColors.gold, + fontSize: 30, + fontWeight: FontWeight.bold, + shadows: [Shadow(color: Colors.black, blurRadius: 8)], + ), + ), + Text( + won ? 'این دست را بردید!' : 'این دست را باختید', + style: TextStyle( + color: won ? AppColors.green : Colors.redAccent, + fontSize: kotWin ? 16 : 20, + fontWeight: FontWeight.bold, + ), ), - ), - const SizedBox(height: 6), - Text( - 'امتیاز: ${r.scores.isNotEmpty ? r.scores[0] : 0} - ${r.scores.length > 1 ? r.scores[1] : 0}', - style: const TextStyle(color: Colors.white70), + if (r.kot && !kotWin) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + r.hakemKot + ? 'حاکم‌کُت! (${r.points} امتیاز)' + : 'کُت! (${r.points} امتیاز)', + style: const TextStyle(color: AppColors.gold), + ), + ), + if (kotWin) + Text('+${r.points} امتیاز', + style: const TextStyle(color: AppColors.gold)), + const SizedBox(height: 6), + Text( + 'امتیاز: ${r.scores.isNotEmpty ? r.scores[0] : 0} - ${r.scores.length > 1 ? r.scores[1] : 0}', + style: const TextStyle(color: Colors.white70), + ), + ], ), - ], + ), ), - ), + ], ), ); } diff --git a/lib/feature/game/presentation/widgets/flame/hokm_game.dart b/lib/feature/game/presentation/widgets/flame/hokm_game.dart index d2b9a51..c719f64 100644 --- a/lib/feature/game/presentation/widgets/flame/hokm_game.dart +++ b/lib/feature/game/presentation/widgets/flame/hokm_game.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show HapticFeedback; import '../../../../../core/service/app_sounds.dart'; +import '../../../../../core/settings/app_settings.dart'; import '../../../../../core/theme/app_theme.dart'; import '../../../domain/entities/game_entities.dart'; import '../../bloc/game_bloc.dart'; @@ -84,6 +85,9 @@ class HokmGame extends FlameGame { @override Future onLoad() async { add(Felt(carpet: carpet)); + if (!AppSettings.I.lowGraphics.value) { + add(Motes()); // ذراتِ طلاییِ معلق برای اتمسفر (در حالتِ کم‌مصرف خاموش) + } _sub = cubit.stream.listen((ui) { if (ui.state == null) return; final s = ui.state!; @@ -172,6 +176,9 @@ class HokmGame extends FlameGame { _bestTrumpRank = rank; _shake = 1.0; add(Lightning()); + if (!AppSettings.I.lowGraphics.value) { + add(CutBurst()); // فلاش + شوک‌ویو + جرقه (کم‌مصرف: خاموش) + } AppSounds.cut(); // بریدن با حکم (شمشیر) HapticFeedback.heavyImpact(); // ضربه‌ی لمسیِ بُرِش با حکم } else { @@ -379,7 +386,8 @@ class HokmGame extends FlameGame { } // حرکتِ نرمِ یک کارت به مقصد، بدون انباشته‌شدنِ افکت‌ها. - void _moveTo(CardComponent c, Vector2 target, {double dur = 0.38}) { + void _moveTo(CardComponent c, Vector2 target, + {double dur = 0.38, bool bounce = false}) { c.children.whereType().toList().forEach( (e) => e.removeFromParent(), ); @@ -387,10 +395,12 @@ class HokmGame extends FlameGame { c.position = target; return; } + // کارتِ تازه‌نشسته روی میز با کمی overshoot می‌نشیند (حسِ «جا افتادن»). c.add( MoveToEffect( target, - EffectController(duration: dur, curve: Curves.easeOutCubic), + EffectController( + duration: dur, curve: bounce ? Curves.easeOutBack : Curves.easeOutCubic), ), ); } @@ -507,6 +517,7 @@ class HokmGame extends FlameGame { for (final tc in s.trick) { final slot = size / 2 + _trickOffset(_rel(tc.seat)); var c = _trick[tc.card]; + final isNew = c == null; // فقط کارتِ تازه‌نشسته bounce می‌شود if (c == null) { // اگر خودِ شما بازی کردید، همان کارتِ دست را منتقل کن (پرواز به وسط). c = _hand.remove(tc.card); @@ -530,7 +541,7 @@ class HokmGame extends FlameGame { c.size = Vector2(_cardW, _cardH); c.angle = 0; // کارت‌های روی زمین صاف‌اند (چرخشِ بادبزنیِ دست حذف می‌شود) c.priority = 5; - _moveTo(c, slot); + _moveTo(c, slot, bounce: isNew); } // دستِ کامل (۴ کارت) ⇒ کارتِ برنده را طلایی هایلایت کن و یک‌بار «پاپ» بزن، diff --git a/lib/feature/game/presentation/widgets/flame/table_pieces.dart b/lib/feature/game/presentation/widgets/flame/table_pieces.dart index ba7df4f..5a4d5e6 100644 --- a/lib/feature/game/presentation/widgets/flame/table_pieces.dart +++ b/lib/feature/game/presentation/widgets/flame/table_pieces.dart @@ -4,6 +4,7 @@ import 'package:flame/components.dart'; import 'package:flutter/material.dart'; import '../../../../../core/network/card_images.dart'; +import '../../../../../core/settings/app_settings.dart'; import '../../../../../core/theme/app_theme.dart'; // رنگ‌های مشترکِ میز (منبعِ واحد در AppColors). @@ -19,6 +20,7 @@ class Felt extends PositionComponent with HasGameReference { Felt({this.carpet = 'classic'}); Sprite? _carpetSprite; + Sprite? _bgSprite; // پس‌زمینه‌ی نقاشی‌شده (اختیاری، قابلِ تعویض) bool get _hasCarpet => carpet != 'classic'; @@ -27,6 +29,15 @@ class Felt extends PositionComponent with HasGameReference { if (_hasCarpet) { _carpetSprite = await CardImages.carpet(carpet); } + // پس‌زمینه‌ی سفارشی (تالار/کاخ): فایلِ assets/images/game_bg.jpg را بگذارید. + // اگر نبود یا حالتِ کم‌مصرف باشد، به گرادیانِ لاجوردیِ پیش‌فرض برمی‌گردد. + if (!AppSettings.I.lowGraphics.value) { + try { + _bgSprite = await Sprite.load('game_bg.jpg'); + } catch (_) { + _bgSprite = null; + } + } } @override @@ -34,18 +45,36 @@ class Felt extends PositionComponent with HasGameReference { 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() - ..shader = RadialGradient( - center: Alignment.center, - radius: 1.0, - colors: const [AppColors.lapisHi, AppColors.lapisNight], - stops: const [0.5, 1.0], - ).createShader(Rect.fromLTWH(0, 0, w, h)), - ); + // ۱) پس‌زمینه: تصویرِ سفارشیِ نقاشی‌شده (اگر موجود باشد) یا گرادیانِ لاجوردیِ + // پیش‌فرض. تصویر با پوششِ کامل (cover) کشیده و کمی تیره می‌شود تا کارت‌ها خوانا + // بمانند و وینیتِ شعاعی هم رویش می‌آید تا لبه‌ها با تم جور شود. + final full = Rect.fromLTWH(0, 0, w, h); + final bg = _bgSprite; + if (bg != null) { + _drawCover(canvas, bg, w, h); + canvas.drawRect(full, Paint()..color = const Color(0x66081428)); + canvas.drawRect( + full, + Paint() + ..shader = RadialGradient( + center: Alignment.center, + radius: 1.1, + colors: const [Color(0x00000000), AppColors.lapisNight], + stops: const [0.55, 1.0], + ).createShader(full), + ); + } else { + canvas.drawRect( + full, + Paint() + ..shader = RadialGradient( + center: Alignment.center, + radius: 1.0, + colors: const [AppColors.lapisHi, AppColors.lapisNight], + stops: const [0.5, 1.0], + ).createShader(full), + ); + } // ۲) میزِ مرکزی با قابِ طلایی. final rect = Rect.fromCenter( @@ -123,6 +152,78 @@ class Felt extends PositionComponent with HasGameReference { ..color = _gold.withValues(alpha: 0.65), ); } + + // تصویرِ پس‌زمینه را با حفظِ نسبت، پوششِ کاملِ صفحه (cover) و مرکز-برش می‌کشد. + void _drawCover(Canvas canvas, Sprite sprite, double w, double h) { + final img = sprite.image; + final iw = img.width.toDouble(), ih = img.height.toDouble(); + final scale = math.max(w / iw, h / ih); + final dw = iw * scale, dh = ih * scale; + sprite.render( + canvas, + position: Vector2((w - dw) / 2, (h - dh) / 2), + size: Vector2(dw, dh), + ); + } +} + +/// ذراتِ طلاییِ معلق (اتمسفرِ سبک). چند نقطه‌ی کم‌رنگ که آرام بالا می‌روند و +/// چشمک می‌زنند؛ عمق می‌دهد بدونِ بارِ گرافیکیِ محسوس (بدونِ blur). +class Motes extends PositionComponent with HasGameReference { + final int count; + Motes({this.count = 12}) { + priority = 1; // بالای Felt، زیرِ کارت‌ها (priority ۵) + } + + final _rng = math.Random(); + double _t = 0; + late final List<_Mote> _motes; + + @override + Future onLoad() async { + _motes = List.generate( + count, + (_) => _Mote( + x: _rng.nextDouble(), + y: _rng.nextDouble(), + r: 1.0 + _rng.nextDouble() * 1.8, + speed: 0.006 + _rng.nextDouble() * 0.014, + drift: (_rng.nextDouble() - 0.5) * 0.03, + phase: _rng.nextDouble() * 6.28, + ), + ); + } + + @override + void update(double dt) => _t += dt; + + @override + void render(Canvas canvas) { + final w = game.size.x, h = game.size.y; + for (final m in _motes) { + var y = (m.y - m.speed * _t) % 1.0; + if (y < 0) y += 1; + final xx = (m.x + m.drift * math.sin(_t * 0.3 + m.phase)) * w; + final tw = (0.35 + 0.28 * math.sin(_t * 1.4 + m.phase)).clamp(0.0, 1.0); + canvas.drawCircle( + Offset(xx, y * h), + m.r, + Paint()..color = _gold.withValues(alpha: tw * 0.45), + ); + } + } +} + +class _Mote { + final double x, y, r, speed, drift, phase; + _Mote({ + required this.x, + required this.y, + required this.r, + required this.speed, + required this.drift, + required this.phase, + }); } /// دیسکِ فلزیِ شمارش (امتیاز تیم) با عددِ وسط؛ از ۰ تا ۷. @@ -161,6 +262,71 @@ class ScorePuck extends PositionComponent { } /// افکت رعد روی زمین هنگام «بریدن با حکم»؛ پس از مدت کوتاهی خودش حذف می‌شود. +/// افکتِ بریدن با حکم: فلاشِ کوتاهِ صفحه + حلقه‌ی شوک‌ویوِ طلایی + جرقه‌ها. +/// همراهِ Lightning اضافه می‌شود تا بُرِش «قدرتمند» حس شود. +class CutBurst extends PositionComponent with HasGameReference { + static const _max = 0.5; + double _life = _max; + final _rng = math.Random(); + late final List<_Spark> _sparks; + + @override + Future onLoad() async { + size = game.size; + _sparks = List.generate(16, (_) { + final ang = _rng.nextDouble() * math.pi * 2; + final spd = 0.10 + _rng.nextDouble() * 0.17; + return _Spark(ang, spd, 1.5 + _rng.nextDouble() * 2.5); + }); + } + + @override + void update(double dt) { + _life -= dt; + if (_life <= 0) removeFromParent(); + } + + @override + void render(Canvas canvas) { + final t = (1 - _life / _max).clamp(0.0, 1.0); // پیشرفت ۰..۱ + final center = Offset(size.x / 2, size.y / 2); + + // ۱) فلاشِ کوتاهِ صفحه (فقط اوایل). + final flash = (1 - t * 3).clamp(0.0, 1.0); + if (flash > 0) { + canvas.drawRect( + Rect.fromLTWH(0, 0, size.x, size.y), + Paint()..color = const Color(0xFFFFF3C4).withValues(alpha: flash * 0.45), + ); + } + + final fade = (1 - t).clamp(0.0, 1.0); + // ۲) حلقه‌ی شوک‌ویوِ طلایی که باز می‌شود. + canvas.drawCircle( + center, + t * size.x * 0.55, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = size.x * 0.022 * fade + ..color = _gold.withValues(alpha: fade * 0.8) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4), + ); + + // ۳) جرقه‌های طلایی که به بیرون می‌پاشند. + final sp = Paint()..color = const Color(0xFFFFF3C4).withValues(alpha: fade); + for (final s in _sparks) { + final d = s.speed * size.x * t; + final p = center + Offset(math.cos(s.ang), math.sin(s.ang)) * d; + canvas.drawCircle(p, s.r * fade, sp); + } + } +} + +class _Spark { + final double ang, speed, r; + _Spark(this.ang, this.speed, this.r); +} + class Lightning extends PositionComponent with HasGameReference { static const _max = 0.55; double _life = _max; diff --git a/lib/feature/game/presentation/widgets/table_hud.dart b/lib/feature/game/presentation/widgets/table_hud.dart index 78ccdf5..df38ec3 100644 --- a/lib/feature/game/presentation/widgets/table_hud.dart +++ b/lib/feature/game/presentation/widgets/table_hud.dart @@ -211,6 +211,22 @@ class TableHud extends StatelessWidget { ); } +/// گرادیانِ قابِ آواتار بر اساسِ نشانِ رتبه (برنز→پادشاه). پیش‌فرض: طلایی. +List _rankFrame(String tier) { + switch (tier) { + case 'bronze': + return const [Color(0xFFCD9B6A), Color(0xFF7A4A22)]; + case 'silver': + return const [Color(0xFFEDF1F4), Color(0xFF98A2AD)]; + case 'diamond': + return const [Color(0xFF9BE7F0), Color(0xFF2E8BA0)]; + case 'king': + return const [Color(0xFFCE9BEA), Color(0xFF6A2FA0)]; + default: // gold و ناشناخته/بات + return const [Color(0xFFF3D27A), AppColors.goldDark]; + } +} + class _PlayerSeat extends StatelessWidget { final GamePlayer player; final double diameter; @@ -271,19 +287,24 @@ class _PlayerSeat extends StatelessWidget { clipBehavior: Clip.none, alignment: Alignment.center, children: [ - // قابِ طلایی + آواتار. + // قابِ رتبه‌ای + آواتار (رنگِ قاب بر اساسِ نشانِ رتبه‌ی بازیکن). Container( width: diameter, height: diameter, decoration: BoxDecoration( shape: BoxShape.circle, - gradient: const LinearGradient( + gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Color(0xFFF3D27A), AppColors.goldDark], + colors: _rankFrame(player.bot ? '' : player.rankTier), ), - boxShadow: const [ - BoxShadow(color: Colors.black54, blurRadius: 6), + boxShadow: [ + const BoxShadow(color: Colors.black54, blurRadius: 6), + if (isTurn) + BoxShadow( + color: _rankFrame(player.rankTier)[0] + .withValues(alpha: 0.6), + blurRadius: 12), ], ), padding: EdgeInsets.all(ringW), diff --git a/lib/feature/profile/presentation/screen/profile_screen.dart b/lib/feature/profile/presentation/screen/profile_screen.dart index a390e8c..c8a2196 100644 --- a/lib/feature/profile/presentation/screen/profile_screen.dart +++ b/lib/feature/profile/presentation/screen/profile_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:random_avatar/random_avatar.dart'; +import '../../../../core/settings/app_settings.dart'; import '../../../../core/theme/app_theme.dart'; import '../../../../core/widgets/game_ui.dart'; import '../../../../core/widgets/rank_badge.dart'; @@ -198,6 +199,8 @@ class ProfileScreen extends StatelessWidget { const SizedBox(height: 8), _statsSection(context, d), const SizedBox(height: 16), + const _GraphicsToggle(), + const SizedBox(height: 8), Align( alignment: Alignment.center, child: TextButton.icon( @@ -490,3 +493,40 @@ class _EditProfileSheetState extends State<_EditProfileSheet> { ); } } + +/// سوئیچِ «گرافیک کم‌مصرف»: افکت‌های اتمسفری را برای گوشی‌های ضعیف خاموش می‌کند. +class _GraphicsToggle extends StatelessWidget { + const _GraphicsToggle(); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: AppSettings.I.lowGraphics, + builder: (context, low, _) => Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + color: AppColors.panel.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.goldFaint), + ), + child: Row( + children: [ + const Icon(Icons.bolt, color: AppColors.gold, size: 20), + const SizedBox(width: 10), + const Expanded( + child: Text( + 'گرافیک کم‌مصرف', + style: TextStyle(color: Colors.white, fontSize: 14), + ), + ), + Switch( + value: low, + activeThumbColor: AppColors.gold, + onChanged: (v) => AppSettings.I.setLowGraphics(v), + ), + ], + ), + ), + ); + } +} diff --git a/lib/feature/wallet/presentation/screen/lobby_screen.dart b/lib/feature/wallet/presentation/screen/lobby_screen.dart index 91c3238..9b1820c 100644 --- a/lib/feature/wallet/presentation/screen/lobby_screen.dart +++ b/lib/feature/wallet/presentation/screen/lobby_screen.dart @@ -13,6 +13,7 @@ import '../bloc/wallet_bloc.dart'; import '../bloc/wallet_event.dart'; import '../bloc/wallet_state.dart'; import '../bloc/wallet_status.dart'; +import '../widgets/coin_fly.dart'; import '../widgets/streak_dialog.dart'; /// لابی اصلی: کیف‌پول، دکمه بازی/دورهمی/فروشگاه/سکه روزانه. @@ -24,6 +25,9 @@ class LobbyScreen extends StatefulWidget { } class _LobbyScreenState extends State { + final _coinKey = GlobalKey(); // چیپِ سکه (مقصدِ پروازِ سکه‌ها) + final _dailyKey = GlobalKey(); // کارتِ هدیه‌ی روزانه (مبدأ) + @override void initState() { super.initState(); @@ -32,6 +36,20 @@ class _LobbyScreenState extends State { void _reload() => context.read().add(LoadWalletEvent()); + Offset? _center(GlobalKey k) { + final box = k.currentContext?.findRenderObject() as RenderBox?; + if (box == null || !box.attached) return null; + return box.localToGlobal(box.size.center(Offset.zero)); + } + + void _flyCoins() { + final from = _center(_dailyKey); + final to = _center(_coinKey); + if (from != null && to != null && mounted) { + CoinFly.play(context, from: from, to: to); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -41,6 +59,7 @@ class _LobbyScreenState extends State { listener: (context, state) { final d = state.dailyStatus; if (d is DailySuccess) { + _flyCoins(); // پروازِ سکه‌ها از کارتِ هدیه به چیپِ سکه ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('سکه روزانه دریافت شد: +${d.amount}')), ); @@ -56,7 +75,10 @@ class _LobbyScreenState extends State { return SafeArea( child: Column( children: [ - _TopBar(wallet: wallet, onCoinTap: () => _openShop(context)), + _TopBar( + wallet: wallet, + coinKey: _coinKey, + onCoinTap: () => _openShop(context)), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 6, 16, 8), @@ -121,6 +143,7 @@ class _LobbyScreenState extends State { ), const SizedBox(height: 14), _DailyCard( + key: _dailyKey, wallet: wallet, onTap: () => StreakDialog.show( @@ -347,7 +370,7 @@ class _ActionTile extends StatelessWidget { class _DailyCard extends StatelessWidget { final WalletEntity? wallet; final VoidCallback onTap; - const _DailyCard({required this.wallet, required this.onTap}); + const _DailyCard({super.key, required this.wallet, required this.onTap}); @override Widget build(BuildContext context) { @@ -478,7 +501,8 @@ class _DailyCard extends StatelessWidget { class _TopBar extends StatelessWidget { final WalletEntity? wallet; final VoidCallback onCoinTap; - const _TopBar({required this.wallet, required this.onCoinTap}); + final Key? coinKey; + const _TopBar({required this.wallet, required this.onCoinTap, this.coinKey}); @override Widget build(BuildContext context) { @@ -582,6 +606,7 @@ class _TopBar extends StatelessWidget { ), const SizedBox(width: 8), GestureDetector( + key: coinKey, onTap: onCoinTap, child: StatChip( icon: Icons.monetization_on, diff --git a/lib/feature/wallet/presentation/widgets/coin_fly.dart b/lib/feature/wallet/presentation/widgets/coin_fly.dart new file mode 100644 index 0000000..bb6af36 --- /dev/null +++ b/lib/feature/wallet/presentation/widgets/coin_fly.dart @@ -0,0 +1,135 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../../../core/theme/app_theme.dart'; + +/// انیمیشنِ «پروازِ سکه‌ها به کیف‌پول»: چند سکه از مبدأ (مثلاً کارتِ هدیه) روی یک +/// قوس به سمتِ مقصد (چیپِ سکه) پرواز می‌کنند. با Overlay نمایش داده می‌شود. +class CoinFly { + static void play( + BuildContext context, { + required Offset from, + required Offset to, + int count = 9, + }) { + final overlay = Overlay.maybeOf(context, rootOverlay: true); + if (overlay == null) return; + late OverlayEntry entry; + entry = OverlayEntry( + builder: (_) => _CoinFlyOverlay( + from: from, + to: to, + count: count, + onDone: () => entry.remove(), + ), + ); + overlay.insert(entry); + } +} + +class _CoinFlyOverlay extends StatefulWidget { + final Offset from, to; + final int count; + final VoidCallback onDone; + const _CoinFlyOverlay({ + required this.from, + required this.to, + required this.count, + required this.onDone, + }); + + @override + State<_CoinFlyOverlay> createState() => _CoinFlyOverlayState(); +} + +class _CoinFlyOverlayState extends State<_CoinFlyOverlay> + with SingleTickerProviderStateMixin { + late final AnimationController _c; + late final List<_Coin> _coins; + + @override + void initState() { + super.initState(); + final r = math.Random(); + _coins = List.generate(widget.count, (_) { + return _Coin( + jitter: Offset((r.nextDouble() - 0.5) * 46, (r.nextDouble() - 0.5) * 46), + delay: r.nextDouble() * 0.28, + arc: -60 - r.nextDouble() * 90, // ارتفاعِ قوس (به بالا) + size: 16 + r.nextDouble() * 8, + ); + }); + _c = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ) + ..addStatusListener((s) { + if (s == AnimationStatus.completed) widget.onDone(); + }) + ..forward(); + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: AnimatedBuilder( + animation: _c, + builder: (_, __) { + return Stack( + children: [ + for (final coin in _coins) _buildCoin(coin), + ], + ); + }, + ), + ); + } + + Widget _buildCoin(_Coin coin) { + final local = ((_c.value - coin.delay) / (1 - coin.delay)).clamp(0.0, 1.0); + if (local <= 0) return const SizedBox.shrink(); + final start = widget.from + coin.jitter; + final end = widget.to; + // قوسِ درجه‌دو: نقطه‌ی کنترل بالای میانه. + final mid = Offset((start.dx + end.dx) / 2, (start.dy + end.dy) / 2 + coin.arc); + final t = Curves.easeInCubic.transform(local); + final p = _quad(start, mid, end, t); + final opacity = local < 0.85 ? 1.0 : (1 - (local - 0.85) / 0.15); + final scale = 1.0 - 0.35 * local; + return Positioned( + left: p.dx - coin.size / 2, + top: p.dy - coin.size / 2, + child: Opacity( + opacity: opacity.clamp(0.0, 1.0), + child: Transform.scale( + scale: scale, + child: Icon(Icons.monetization_on, + color: AppColors.gold, size: coin.size), + ), + ), + ); + } + + Offset _quad(Offset a, Offset b, Offset c, double t) { + final u = 1 - t; + return a * (u * u) + b * (2 * u * t) + c * (t * t); + } +} + +class _Coin { + final Offset jitter; + final double delay, arc, size; + _Coin({ + required this.jitter, + required this.delay, + required this.arc, + required this.size, + }); +} diff --git a/lib/main.dart b/lib/main.dart index d7d50f7..51e8986 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,11 +3,13 @@ import 'package:flutter/material.dart'; import 'app.dart'; import 'core/locator/locator.dart'; import 'core/service/app_sounds.dart'; +import 'core/settings/app_settings.dart'; import 'feature/auth/domain/repository/auth_repository.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); await setupLocator(); + await AppSettings.I.load(); // تنظیماتِ گرافیک AppSounds.preload(); // پیش‌بارگذاریِ صداها (بدون انتظار) final loggedIn = await locator().isLoggedIn(); runApp(HakemApp(loggedIn: loggedIn));