import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers.dart'; import '../game/enums.dart'; import '../game/models.dart'; import 'dart:async'; import '../widgets.dart'; import '../utils.dart'; import 'main_menu_screen.dart'; import '../game/config.dart'; import '../widgets/battle/effect_sprite_widget.dart'; enum AnimationPhase { none, start, middle, end } class BattleScreen extends StatefulWidget { const BattleScreen({super.key}); @override State createState() => _BattleScreenState(); } class _BattleScreenState extends State { final List _floatingDamageTexts = []; final List _floatingEffects = []; final List _floatingFeedbackTexts = []; StreamSubscription? _damageSubscription; StreamSubscription? _effectSubscription; StreamSubscription? _healSubscription; final GlobalKey _playerKey = GlobalKey(); final GlobalKey _enemyKey = GlobalKey(); final GlobalKey _stackKey = GlobalKey(); final GlobalKey _shakeKey = GlobalKey(); final GlobalKey _playerAnimKey = GlobalKey(); final GlobalKey _enemyAnimKey = GlobalKey(); // Added Enemy Anim Key final GlobalKey _explosionKey = GlobalKey(); final GlobalKey _effectSpriteKey = GlobalKey(); bool _showLogs = false; bool _isPlayerAttacking = false; // Player Attack Animation State bool _isEnemyAttacking = false; // Enemy Attack Animation State bool _showEquipmentSwapPanel = false; bool _isCompletingReward = false; DateTime? _lastFeedbackTime; // Cooldown to prevent duplicate feedback texts // New State for Interactive Defense Animation int _lastTurnCount = -1; bool _hasShownEnemyDefense = false; AnimationPhase _playerAnimPhase = AnimationPhase.none; String? _getOverrideImage(bool isPlayer) { if (!isPlayer) { return null; // Enemy animation image logic can be added later } if (_playerAnimPhase == AnimationPhase.start) { return "assets/images/character/warrior_attack_1.png"; } else if (_playerAnimPhase == AnimationPhase.middle) { return null; // Middle phase now uses default image or another image } else if (_playerAnimPhase == AnimationPhase.end) { return "assets/images/character/warrior_attack_2.png"; } return null; } @override void initState() { super.initState(); final battleProvider = context.read(); _damageSubscription = battleProvider.damageStream.listen( _addFloatingDamageText, ); _effectSubscription = battleProvider.effectStream.listen( _addFloatingEffect, ); _healSubscription = battleProvider.healStream.listen(_onHealEvent); } @override void dispose() { _damageSubscription?.cancel(); _effectSubscription?.cancel(); _healSubscription?.cancel(); super.dispose(); } void _addFloatingDamageText(DamageEvent event) { if (!mounted) return; GlobalKey targetKey = event.target == DamageTarget.player ? _playerKey : _enemyKey; if (targetKey.currentContext == null) return; RenderBox? renderBox = targetKey.currentContext!.findRenderObject() as RenderBox?; if (renderBox == null) return; Offset position = renderBox.localToGlobal(Offset.zero); RenderBox? stackRenderBox = _stackKey.currentContext?.findRenderObject() as RenderBox?; if (stackRenderBox != null) { Offset stackOffset = stackRenderBox.localToGlobal(Offset.zero); position = position - stackOffset; } position = position + Offset( renderBox.size.width / 2 + BattleConfig.damageTextOffsetX, BattleConfig.damageTextOffsetY, ); final String id = UniqueKey().toString(); final double scale = event.risk == RiskLevel.risky || (event.risk == null && event.damage > BattleConfig.highDamageThreshold) ? BattleConfig.damageScaleHigh : BattleConfig.damageScaleNormal; setState(() { _floatingDamageTexts.add( DamageTextData( id: id, widget: Positioned( key: ValueKey('pos_$id'), left: position.dx, top: position.dy, child: Transform.scale( scale: scale, child: FloatingDamageText( key: ValueKey(id), damage: event.damage.toString(), color: event.color, onRemove: () { if (mounted) { setState(() { _floatingDamageTexts.removeWhere((e) => e.id == id); }); } }, ), ), ), ), ); }); } void _onHealEvent(HealEvent event) { if (!mounted) return; // Find position: Default to center of screen Offset position = Offset( MediaQuery.of(context).size.width / 2, MediaQuery.of(context).size.height / 2, ); // Try to get player's position if visible (in Battle UI) if (event.target == HealTarget.player && _playerKey.currentContext != null) { RenderBox? renderBox = _playerKey.currentContext!.findRenderObject() as RenderBox?; if (renderBox != null) { position = renderBox.localToGlobal( Offset(renderBox.size.width / 2, renderBox.size.height / 2), ); } } // Play visual effect (heal.png has 4 frames) _effectSpriteKey.currentState?.playEffect( position: position, assetPath: 'assets/images/effects/heal.png', frameCount: 4, tileWidth: 100.0, // Assuming each frame is 100x100 tileHeight: 100.0, scale: 2.0, ); // Play floating text final String id = UniqueKey().toString(); setState(() { _floatingDamageTexts.add( DamageTextData( id: id, widget: Positioned( key: ValueKey('pos_$id'), left: position.dx + BattleConfig.damageTextOffsetX, top: position.dy + BattleConfig.damageTextOffsetY, child: FloatingDamageText( key: ValueKey(id), damage: "+${event.amount}", color: ThemeConfig.statHpPlayerColor, // Green color for heal onRemove: () { if (mounted) { setState(() { _floatingDamageTexts.removeWhere((e) => e.id == id); }); } }, ), ), ), ); }); } final Set _processedEffectIds = {}; void _addFloatingEffect(EffectEvent event) { if (_processedEffectIds.contains(event.id)) { return; } _processedEffectIds.add(event.id); // Keep the set size manageable if (_processedEffectIds.length > 50) { _processedEffectIds.remove(_processedEffectIds.first); } if (!mounted) return; bool shouldShowFeedback = true; if (event.feedbackType != null) { final now = DateTime.now(); if (_lastFeedbackTime != null && now.difference(_lastFeedbackTime!).inMilliseconds < BattleConfig.feedbackCooldownMs) { shouldShowFeedback = false; } else { _lastFeedbackTime = now; } } GlobalKey targetKey = event.target == EffectTarget.player ? _playerKey : _enemyKey; if (targetKey.currentContext == null) return; RenderBox? renderBox = targetKey.currentContext!.findRenderObject() as RenderBox?; if (renderBox == null) return; Offset position = renderBox.localToGlobal(Offset.zero); RenderBox? stackRenderBox = _stackKey.currentContext?.findRenderObject() as RenderBox?; if (stackRenderBox != null) { Offset stackOffset = stackRenderBox.localToGlobal(Offset.zero); position = position - stackOffset; } // Adjust position based on target: // Enemy (Top Right) -> Effect to the left/bottom of character (towards player) // Player (Bottom Left) -> Effect to the right/top of character (towards enemy) double offsetX = 0; double offsetY = 0; if (event.target == EffectTarget.enemy) { // Enemy is top-right, so effect should be left-bottom of its card offsetX = renderBox.size.width * BattleConfig.effectEnemyOffsetX; offsetY = renderBox.size.height * BattleConfig.effectEnemyOffsetY; } else { // Player is bottom-left, so effect should be right-top of its card offsetX = renderBox.size.width * BattleConfig.effectPlayerOffsetX; offsetY = renderBox.size.height * BattleConfig.effectPlayerOffsetY; } position = position + Offset(offsetX, offsetY); // 0. Prepare Effect Function void showEffect() { if (!mounted) return; // Handle Feedback Text (MISS / FAILED) if (event.feedbackType != null) { if (!shouldShowFeedback) return; String feedbackText; Color feedbackColor; switch (event.feedbackType) { case BattleFeedbackType.miss: feedbackText = "MISS"; feedbackColor = ThemeConfig.missText; break; case BattleFeedbackType.failed: feedbackText = "FAILED"; feedbackColor = ThemeConfig.failedText; break; case BattleFeedbackType.dodge: feedbackText = "DODGE"; feedbackColor = ThemeConfig.statLuckColor; // Use Luck color (Greenish) break; default: feedbackText = ""; feedbackColor = ThemeConfig.textColorWhite; } final String id = UniqueKey().toString(); // Prevent duplicate feedback texts for the same event ID (UI Level) if (_floatingFeedbackTexts.any((e) => e.eventId == event.id)) { return; } setState(() { _floatingFeedbackTexts.clear(); // Clear previous texts _floatingFeedbackTexts.add( FeedbackTextData( id: id, eventId: event.id, widget: Positioned( key: ValueKey('pos_$id'), left: position.dx, top: position.dy, child: FloatingFeedbackText( key: ValueKey(id), feedback: feedbackText, color: feedbackColor, onRemove: () { if (mounted) { setState(() { _floatingFeedbackTexts.removeWhere((e) => e.id == id); }); } }, ), ), ), ); }); return; // Return early for feedback } // Handle Icon Effect IconData icon = BattleConfig.getIcon(event.type); Color color = BattleConfig.getColor(event.type, event.risk); double size = BattleConfig.getSize(event.risk); final String id = UniqueKey().toString(); setState(() { _floatingEffects.add( FloatingEffectData( id: id, widget: Positioned( key: ValueKey('pos_$id'), left: position.dx, top: position.dy, child: FloatingEffect( key: ValueKey(id), icon: icon, color: color, size: size, onRemove: () { if (mounted) { setState(() { _floatingEffects.removeWhere((e) => e.id == id); }); } }, ), ), ), ); }); } // 1. Player Attack Animation Trigger (Success or Miss) if (event.isVisualOnly) { showEffect(); context.read().handleImpact(event); } else if (event.type == ActionType.attack && event.target == EffectTarget.enemy) { final RenderBox? playerBox = _playerKey.currentContext?.findRenderObject() as RenderBox?; final RenderBox? enemyBox = _enemyKey.currentContext?.findRenderObject() as RenderBox?; if (playerBox != null && enemyBox != null) { final playerPos = playerBox.localToGlobal(Offset.zero); final enemyPos = enemyBox.localToGlobal(Offset.zero); final offset = enemyPos - playerPos; setState(() { _isPlayerAttacking = true; }); // Force SAFE animation for MISS, otherwise use event risk final RiskLevel animRisk = event.feedbackType != null ? RiskLevel.safe : event.risk; _playerAnimKey.currentState ?.animateAttack( offset, () { showEffect(); context.read().handleImpact(event); if (event.risk == RiskLevel.risky && event.feedbackType == null) { _shakeKey.currentState?.shake(); RenderBox? stackBox = _stackKey.currentContext?.findRenderObject() as RenderBox?; if (stackBox != null) { Offset localEnemyPos = stackBox.globalToLocal(enemyPos); localEnemyPos += Offset( enemyBox.size.width / 2, enemyBox.size.height / 2, ); _explosionKey.currentState?.explode(localEnemyPos); } } }, animRisk, onAnimationStart: () { if (mounted) { setState(() => _playerAnimPhase = AnimationPhase.start); } }, onAnimationMiddle: () { if (mounted) { setState(() => _playerAnimPhase = AnimationPhase.middle); } }, onAnimationEnd: () { if (mounted) { setState(() => _playerAnimPhase = AnimationPhase.end); } }, ) .then((_) { if (mounted) { setState(() { _isPlayerAttacking = false; _playerAnimPhase = AnimationPhase.none; }); } }); } } // 2. Enemy Attack Animation Trigger (Success or Miss) else if (event.type == ActionType.attack && event.target == EffectTarget.player) { bool enableAnim = context.read().enableEnemyAnimations; if (!enableAnim) { showEffect(); context.read().handleImpact(event); return; } final RenderBox? playerBox = _playerKey.currentContext?.findRenderObject() as RenderBox?; final RenderBox? enemyBox = _enemyKey.currentContext?.findRenderObject() as RenderBox?; if (playerBox != null && enemyBox != null) { final playerPos = playerBox.localToGlobal(Offset.zero); final enemyPos = enemyBox.localToGlobal(Offset.zero); final offset = playerPos - enemyPos; setState(() { _isEnemyAttacking = true; }); // Force SAFE animation for MISS final RiskLevel animRisk = event.feedbackType != null ? RiskLevel.safe : event.risk; _enemyAnimKey.currentState ?.animateAttack(offset, () { showEffect(); context.read().handleImpact(event); if (event.risk == RiskLevel.risky && event.feedbackType == null) { _shakeKey.currentState?.shake(); RenderBox? stackBox = _stackKey.currentContext?.findRenderObject() as RenderBox?; if (stackBox != null) { Offset localPlayerPos = stackBox.globalToLocal(playerPos); localPlayerPos += Offset( playerBox.size.width / 2, playerBox.size.height / 2, ); _explosionKey.currentState?.explode(localPlayerPos); } } }, animRisk) .then((_) { if (mounted) { setState(() { _isEnemyAttacking = false; }); } }); } } // 3. Defend Animation Trigger (Success OR Failure) else if (event.type == ActionType.defend) { if (event.target == EffectTarget.player) { setState(() => _isPlayerAttacking = true); // Reuse flag to block input _playerAnimKey.currentState ?.animateDefense(() { showEffect(); context.read().handleImpact(event); }) .then((_) { if (mounted) setState(() => _isPlayerAttacking = false); }); } else if (event.target == EffectTarget.enemy) { // Check settings for enemy animation bool enableAnim = context .read() .enableEnemyAnimations; if (!enableAnim) { showEffect(); context.read().handleImpact(event); return; } setState(() => _isEnemyAttacking = true); // Reuse flag to block input _enemyAnimKey.currentState ?.animateDefense(() { showEffect(); context.read().handleImpact(event); }) .then((_) { if (mounted) setState(() => _isEnemyAttacking = false); }); } else { showEffect(); context.read().handleImpact(event); } } // 4. Others (Feedback for attacks, Buffs, etc.) else { showEffect(); // If it's a feedback event (MISS/FAILED for attacks), wait 500ms. if (event.feedbackType != null) { Future.delayed(const Duration(milliseconds: 500), () { if (mounted) context.read().handleImpact(event); }); } else { // Success events (Icon) context.read().handleImpact(event); } } } void _showRiskLevelSelection(BuildContext context, ActionType actionType) { if (_showEquipmentSwapPanel) { setState(() => _showEquipmentSwapPanel = false); } // 1. Check if we need to trigger enemy animation first bool triggered = _triggerEnemyDefenseIfNeeded(context); if (triggered) { return; // If triggered, we wait for animation (and input block) } final battleProvider = context.read(); final player = battleProvider.player; showDialog( context: context, builder: (BuildContext context) { return RiskSelectionDialog( actionType: actionType, player: player, onSelected: (risk) { context.read().playerAction(actionType, risk); Navigator.pop(context); }, ); }, ); } /// Triggers enemy defense animation if applicable. Returns true if triggered. bool _triggerEnemyDefenseIfNeeded(BuildContext context) { final battleProvider = context.read(); // Check turn to reset flag if (battleProvider.turnCount != _lastTurnCount) { _lastTurnCount = battleProvider.turnCount; _hasShownEnemyDefense = false; } final enemyIntent = battleProvider.currentEnemyIntent; if (enemyIntent != null && enemyIntent.type == EnemyActionType.defend && !_hasShownEnemyDefense && context.read().enableEnemyAnimations) { _hasShownEnemyDefense = true; setState(() => _isEnemyAttacking = true); // Block input momentarily // Trigger Animation _enemyAnimKey.currentState ?.animateDefense(() { // [New] Apply Logic Synced with Animation battleProvider.applyPendingEnemyDefense(); // Create a local visual-only event to trigger the effect (Icon or FAILED text) final bool isSuccess = enemyIntent.isSuccess; final BattleFeedbackType? feedbackType = isSuccess ? null : BattleFeedbackType.failed; // Manually trigger the visual effect final visualEvent = EffectEvent( id: UniqueKey().toString(), // Local unique ID type: ActionType.defend, risk: enemyIntent.risk, target: EffectTarget.enemy, // Show on enemy feedbackType: feedbackType, attacker: battleProvider.enemy, targetEntity: battleProvider.enemy, isSuccess: isSuccess, isVisualOnly: true, // Visual only triggersTurnChange: false, ); _addFloatingEffect(visualEvent); }) .then((_) { if (mounted) setState(() => _isEnemyAttacking = false); }); return true; } return false; } void _showInventoryDialog(BuildContext context) { if (_showEquipmentSwapPanel) { setState(() => _showEquipmentSwapPanel = false); } final battleProvider = context.read(); final List consumables = battleProvider.player.inventory .where((item) => item.slot == EquipmentSlot.consumable) .toList(); if (consumables.isEmpty) { ToastUtils.showTopToast(context, "No consumable items!"); return; } showDialog( context: context, builder: (context) { return SimpleDialog( title: const Text("Use Item"), children: consumables.map((item) { return SimpleDialogOption( onPressed: () { battleProvider.useConsumable(item); Navigator.pop(context); }, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: ThemeConfig.rewardItemBg, borderRadius: BorderRadius.circular(4), border: Border.all( color: ItemUtils.getRarityColor(item.rarity), ), ), child: Image.asset( ItemUtils.getIconPath(item.slot), width: ThemeConfig.itemIconSizeMedium, height: ThemeConfig.itemIconSizeMedium, fit: BoxFit.contain, ), ), const SizedBox(width: 12), Text( item.name, style: TextStyle( fontWeight: ThemeConfig.fontWeightBold, fontSize: ThemeConfig.fontSizeLarge, color: ItemUtils.getRarityColor(item.rarity), ), ), ], ), _buildItemStatText(item), ], ), ); }).toList(), ); }, ); } void _toggleEquipmentSwapPanel() { setState(() { _showEquipmentSwapPanel = !_showEquipmentSwapPanel; }); } Widget _buildEquipmentSwapPanel() { return Material( color: Colors.transparent, child: Container( height: 236, decoration: BoxDecoration( color: ThemeConfig.battleBg.withValues(alpha: 0.92), border: Border.all(color: ThemeConfig.textColorGrey), borderRadius: BorderRadius.circular(8), boxShadow: const [ BoxShadow( color: Colors.black54, blurRadius: 10, offset: Offset(0, 4), ), ], ), child: Column( children: [ SizedBox( height: 40, child: Row( children: [ const SizedBox(width: 10), const Icon( Icons.swap_horiz, color: ThemeConfig.mainIconColor, size: 20, ), const SizedBox(width: 6), const Expanded( child: Text( "Equipment", maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: ThemeConfig.textColorWhite, fontSize: ThemeConfig.fontSizeBody, fontWeight: ThemeConfig.fontWeightBold, ), ), ), IconButton( visualDensity: VisualDensity.compact, onPressed: () { setState(() => _showEquipmentSwapPanel = false); }, icon: const Icon( Icons.close, color: ThemeConfig.textColorWhite, size: 18, ), ), ], ), ), const Divider(height: 1, color: ThemeConfig.textColorGrey), const Expanded( child: InventoryGridWidget( mode: InventoryGridMode.equipmentSwap, equipmentOnly: true, showHeader: false, gridPadding: EdgeInsets.all(8.0), childAspectRatio: 1.05, ), ), ], ), ), ); } bool _canUseEquipmentSwap(BattleProvider battleProvider) { return battleProvider.isPlayerTurn && !battleProvider.player.isDead && !battleProvider.enemy.isDead && !battleProvider.showRewardPopup && !_isPlayerAttacking && !_isEnemyAttacking; } Widget _buildEquipmentSwapButton(BattleProvider battleProvider) { final canUse = _canUseEquipmentSwap(battleProvider); return FloatingActionButton( heroTag: "equipmentSwap", mini: true, backgroundColor: canUse ? ThemeConfig.toggleBtnBg : ThemeConfig.btnDisabled, onPressed: canUse ? _toggleEquipmentSwapPanel : null, child: Icon( _showEquipmentSwapPanel && canUse ? Icons.close : Icons.swap_horiz, color: ThemeConfig.textColorWhite, ), ); } bool get _hasPendingBattleAnimations { return _isPlayerAttacking || _isEnemyAttacking || _floatingDamageTexts.isNotEmpty || _floatingEffects.isNotEmpty || _floatingFeedbackTexts.isNotEmpty || (_explosionKey.currentState?.isAnimating ?? false); } Future _waitForBattleAnimationsToSettle() async { final deadline = DateTime.now().add( AnimationConfig.attackRiskyTotal + AnimationConfig.floatingTextDuration + const Duration(milliseconds: 400), ); while (mounted && _hasPendingBattleAnimations && DateTime.now().isBefore(deadline)) { await Future.delayed(const Duration(milliseconds: 50)); } } Future _selectRewardAfterAnimationsIfNeeded(Item item) async { if (_isCompletingReward) return; final battleProvider = context.read(); final shouldWaitForShop = battleProvider.nextStageType == StageType.shop && _hasPendingBattleAnimations; setState(() => _isCompletingReward = true); final success = battleProvider.selectReward(item, completeStage: false); if (!success) { if (mounted) { setState(() => _isCompletingReward = false); ToastUtils.showTopToast( context, "${AppStrings.inventoryFull} Cannot take item.", ); } return; } if (shouldWaitForShop) { await _waitForBattleAnimationsToSettle(); } if (!mounted) return; context.read().completeStage(); setState(() => _isCompletingReward = false); } @override Widget build(BuildContext context) { return ResponsiveContainer( child: Stack( children: [ Consumer( builder: (context, battleProvider, child) { if (battleProvider.currentStage.type == StageType.shop) { return ShopUI(battleProvider: battleProvider); } else if (battleProvider.currentStage.type == StageType.rest) { return RestUI(battleProvider: battleProvider); } return _buildBattleUI(battleProvider); }, ), EffectSpriteWidget(key: _effectSpriteKey), ], ), ); } Widget _buildBattleUI(BattleProvider battleProvider) { return ShakeWidget( key: _shakeKey, child: Stack( key: _stackKey, children: [ // 1. Background Image Container( decoration: const BoxDecoration( image: DecorationImage( image: AssetImage('assets/images/background/tier_1.jpg'), fit: BoxFit.cover, ), ), ), // 1.1 Opacity Layer Container(color: Colors.black.withValues(alpha: 0.7)), // 2. Battle Content (Top Bar + Characters) Column( children: [ // Top Bar const BattleHeader(), // Battle Area (Characters) - Expanded to fill available space Expanded( child: Padding( padding: const EdgeInsets.all(16.0), child: Stack( children: [ // Player (Bottom Left) - Rendered First Positioned( bottom: 80, // Space for FABs left: 16, // Add some padding from left child: CharacterStatusCard( character: battleProvider.player, isPlayer: true, isTurn: battleProvider.isPlayerTurn, key: _playerKey, animationKey: _playerAnimKey, hideStats: _isPlayerAttacking, overrideImage: _getOverrideImage(true), ), ), // Enemy (Top Right) - Rendered Last (On Top) Positioned( top: 16, // Add some padding from top right: 16, // Add some padding from right child: CharacterStatusCard( character: battleProvider.enemy, isPlayer: false, isTurn: !battleProvider.isPlayerTurn, key: _enemyKey, animationKey: _enemyAnimKey, // Direct Pass hideStats: _isEnemyAttacking, ), ), ], // Close children list ), // Close Stack ), // Close Padding ), // Close Expanded ], // Close Column ), // Close Column // 3. Logs Overlay if (_showLogs && battleProvider.logs.isNotEmpty) Positioned( top: 60, left: 16, right: 16, height: BattleConfig.logsOverlayHeight, child: BattleLogOverlay(logs: battleProvider.logs), ), // 4. Battle Controls (Bottom Right) Positioned( bottom: 20, right: 20, child: BattleControls( isAttackEnabled: battleProvider.isPlayerTurn && !battleProvider.player.isDead && !battleProvider.enemy.isDead && !battleProvider.showRewardPopup && !_isPlayerAttacking && !_isEnemyAttacking, // Enabled even if disarmed (damage reduced) isDefendEnabled: battleProvider.isPlayerTurn && !battleProvider.player.isDead && !battleProvider.enemy.isDead && !battleProvider.showRewardPopup && !_isPlayerAttacking && !_isEnemyAttacking && !battleProvider.player.hasStatus( StatusEffectType.defenseForbidden, ), // Disable if defense is forbidden onAttackPressed: () => _showRiskLevelSelection(context, ActionType.attack), onDefendPressed: () => _showRiskLevelSelection(context, ActionType.defend), onItemPressed: () => _showInventoryDialog(context), ), ), if (_showEquipmentSwapPanel && _canUseEquipmentSwap(battleProvider)) Positioned( bottom: 20, right: 96, width: 260, child: _buildEquipmentSwapPanel(), ), // 5. Log Toggle Button (Bottom Left) Positioned( bottom: 20, left: 20, child: Column( mainAxisSize: MainAxisSize.min, children: [ _buildEquipmentSwapButton(battleProvider), const SizedBox(height: 12), FloatingActionButton( heroTag: "logToggle", mini: true, backgroundColor: ThemeConfig.toggleBtnBg, onPressed: () { setState(() { _showLogs = !_showLogs; }); }, child: Icon( _showLogs ? Icons.visibility_off : Icons.visibility, color: ThemeConfig.textColorWhite, ), ), ], ), ), // Reward Popup if (battleProvider.showRewardPopup) Container( color: ThemeConfig.cardBgColor, child: Center( child: SimpleDialog( title: Row( children: [ const Text( "${AppStrings.victory} ${AppStrings.chooseReward}", ), const Spacer(), Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.monetization_on, color: ThemeConfig.statGoldColor, size: ThemeConfig.itemIconSizeSmall, ), const SizedBox(width: 4), Text( "${battleProvider.lastGoldReward} G", style: TextStyle( color: ThemeConfig.statGoldColor, fontSize: ThemeConfig.fontSizeBody, fontWeight: ThemeConfig.fontWeightBold, ), ), ], ), ], ), children: battleProvider.rewardOptions.map((item) { bool isSkip = item.id == "reward_skip"; return SimpleDialogOption( onPressed: _isCompletingReward ? null : () => _selectRewardAfterAnimationsIfNeeded(item), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ if (!isSkip) Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: ThemeConfig.rewardItemBg, borderRadius: BorderRadius.circular(4), border: Border.all( color: item.rarity != ItemRarity.magic ? ItemUtils.getRarityColor( item.rarity, ) : ThemeConfig.rarityCommon, ), ), child: Image.asset( ItemUtils.getIconPath(item.slot), width: ThemeConfig.itemIconSizeMedium, height: ThemeConfig.itemIconSizeMedium, fit: BoxFit.contain, filterQuality: FilterQuality.high, ), ), if (!isSkip) const SizedBox(width: 12), Text( item.name, style: TextStyle( fontWeight: ThemeConfig.fontWeightBold, fontSize: ThemeConfig.fontSizeLarge, color: isSkip ? ThemeConfig.textColorGrey : ItemUtils.getRarityColor(item.rarity), ), ), ], ), if (!isSkip) _buildItemStatText(item), Text( item.description, style: const TextStyle( fontSize: ThemeConfig.fontSizeMedium, color: ThemeConfig.textColorGrey, ), ), ], ), ); }).toList(), ), ), ), // Floating Effects ..._floatingDamageTexts.map((e) => e.widget), ..._floatingEffects.map((e) => e.widget), ..._floatingFeedbackTexts.map((e) => e.widget), // Explosion Layer ExplosionWidget(key: _explosionKey), // Game Over Overlay if (battleProvider.player.isDead) Container( color: ThemeConfig.battleBg, child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Text( AppStrings.defeat, style: TextStyle( color: ThemeConfig.statHpColor, fontSize: ThemeConfig.fontSizeHuge, fontWeight: ThemeConfig.fontWeightBold, letterSpacing: ThemeConfig.letterSpacingHeader, ), ), const SizedBox(height: 32), ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: ThemeConfig.menuButtonBg, padding: const EdgeInsets.symmetric( horizontal: ThemeConfig.paddingBtnHorizontal, vertical: ThemeConfig.paddingBtnVertical, ), ), onPressed: () { Navigator.of(context).pushAndRemoveUntil( MaterialPageRoute( builder: (context) => const MainMenuScreen(), ), (route) => false, ); }, child: const Text( AppStrings.returnToMenu, style: TextStyle( color: ThemeConfig.textColorWhite, fontSize: ThemeConfig.fontSizeHeader, ), ), ), ], ), ), ), ], ), ); } Widget _buildItemStatText(Item item) { List stats = []; if (item.atkBonus > 0) stats.add("+${item.atkBonus} ${AppStrings.atk}"); if (item.hpBonus > 0) stats.add("+${item.hpBonus} ${AppStrings.hp}"); if (item.armorBonus > 0) stats.add("+${item.armorBonus} ${AppStrings.def}"); if (item.luck > 0) stats.add("+${item.luck} ${AppStrings.luck}"); if (item.dodge > 0) stats.add("+${item.dodge}% Dodge"); // Add Dodge List effectTexts = item.effects.map((e) => e.description).toList(); if (stats.isEmpty && effectTexts.isEmpty) return const SizedBox.shrink(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (stats.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 4.0, bottom: 4.0), child: Text( stats.join(", "), style: const TextStyle( fontSize: ThemeConfig.fontSizeMedium, color: ThemeConfig.statAtkColor, ), ), ), if (effectTexts.isNotEmpty) Padding( padding: const EdgeInsets.only(bottom: 4.0), child: Text( effectTexts.join(", "), style: const TextStyle( fontSize: 11, color: ThemeConfig.rarityLegendary, ), // 11 is custom, keep or change? Let's use Small ), ), ], ); } }