feat: add subweapon support, weapon type logic, and compact item card UI options

This commit is contained in:
2026-04-28 01:39:39 +09:00
parent 0420e23939
commit 0e0748540e
46 changed files with 1795 additions and 577 deletions
@@ -33,6 +33,14 @@ class EquippedItemsWidget extends StatelessWidget {
.where((slot) => slot != EquipmentSlot.consumable)
.map((slot) {
final item = player.equipment[slot];
bool isShieldLocked = false;
if (slot == EquipmentSlot.shield) {
final mainWeapon = player.equipment[EquipmentSlot.weapon];
if (mainWeapon != null && mainWeapon.weaponType == WeaponType.twoHanded) {
isShieldLocked = true;
}
}
return Expanded(
child: InkWell(
onTap: item != null
@@ -45,7 +53,7 @@ class EquippedItemsWidget extends StatelessWidget {
child: Card(
color: item != null
? ThemeConfig.equipmentCardBg
: ThemeConfig.emptySlotBg,
: (isShieldLocked ? Colors.black26 : ThemeConfig.emptySlotBg),
shape:
item != null && item.rarity != ItemRarity.magic
? RoundedRectangleBorder(
@@ -65,7 +73,7 @@ class EquippedItemsWidget extends StatelessWidget {
right: 4,
top: 4,
child: Text(
slot.name.toUpperCase(),
ItemUtils.getSlotLabel(slot),
style: const TextStyle(
fontSize: ThemeConfig.fontSizeTiny,
fontWeight: ThemeConfig.fontWeightBold,
@@ -78,7 +86,7 @@ class EquippedItemsWidget extends StatelessWidget {
left: 4,
top: 4,
child: Opacity(
opacity: item != null ? 0.5 : 0.2,
opacity: item != null ? 0.5 : (isShieldLocked ? 0.1 : 0.2),
child: Image.asset(
ItemUtils.getIconPath(slot),
width: 40,
@@ -100,7 +108,7 @@ class EquippedItemsWidget extends StatelessWidget {
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
item?.name ?? AppStrings.emptySlot,
item?.name ?? (isShieldLocked ? "Locked (2H)" : AppStrings.emptySlot),
textAlign: TextAlign.center,
style: TextStyle(
fontSize:
@@ -111,7 +119,7 @@ class EquippedItemsWidget extends StatelessWidget {
? ItemUtils.getRarityColor(
item.rarity,
)
: ThemeConfig.textColorGrey,
: (isShieldLocked ? Colors.red.withOpacity(0.5) : ThemeConfig.textColorGrey),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
@@ -181,6 +189,16 @@ class EquippedItemsWidget extends StatelessWidget {
_buildStatChangeRow("Current HP", currentHp, newHp),
_buildStatChangeRow(AppStrings.atk, currentAtk, newAtk),
_buildStatChangeRow(AppStrings.def, currentDef, newDef),
if (itemToUnequip.effects.isNotEmpty) ...[
const Divider(color: ThemeConfig.textColorGrey, height: 16),
...itemToUnequip.effects.map((e) => Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("- ", style: TextStyle(color: ThemeConfig.textColorGrey, fontSize: 12)),
Expanded(child: Text(e.description, style: const TextStyle(color: ThemeConfig.textColorGrey, fontSize: 12))),
],
)),
],
],
),
actions: [
+201 -72
View File
@@ -4,75 +4,123 @@ import '../../providers.dart';
import '../../game/models.dart';
import '../../game/enums.dart';
import '../../game/config.dart';
import '../../utils.dart';
import '../common/item_card_widget.dart';
enum InventoryGridMode { normal, shop, equipmentSwap }
class InventoryGridWidget extends StatelessWidget {
const InventoryGridWidget({super.key});
final InventoryGridMode mode;
final bool equipmentOnly;
final bool showHeader;
final int crossAxisCount;
final EdgeInsetsGeometry gridPadding;
final double childAspectRatio;
const InventoryGridWidget({
super.key,
this.mode = InventoryGridMode.normal,
this.equipmentOnly = false,
this.showHeader = true,
this.crossAxisCount = 4,
this.gridPadding = const EdgeInsets.all(16.0),
this.childAspectRatio = 1.0,
});
@override
Widget build(BuildContext context) {
return Consumer<BattleProvider>(
builder: (context, battleProvider, child) {
final player = battleProvider.player;
final items = equipmentOnly
? player.inventory
.where((item) => item.slot != EquipmentSlot.consumable)
.toList()
: player.inventory;
final itemCount = mode == InventoryGridMode.equipmentSwap
? items.length
: player.maxInventorySize;
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
"${AppStrings.bag} (${player.inventory.length}/${player.maxInventorySize})",
style: const TextStyle(
fontSize: ThemeConfig.fontSizeHeader,
fontWeight: ThemeConfig.fontWeightBold,
if (showHeader)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
"${equipmentOnly ? AppStrings.equipment : AppStrings.bag} (${items.length}/${player.maxInventorySize})",
style: const TextStyle(
fontSize: ThemeConfig.fontSizeHeader,
fontWeight: ThemeConfig.fontWeightBold,
),
),
),
),
),
Expanded(
child: GridView.builder(
padding: const EdgeInsets.all(16.0),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: 8.0,
mainAxisSpacing: 8.0,
),
itemCount: player.maxInventorySize,
itemBuilder: (context, index) {
if (index < player.inventory.length) {
final item = player.inventory[index];
return InkWell(
onTap: () {
_showItemActionDialog(context, battleProvider, item);
child: itemCount == 0
? const Center(
child: Text(
"No equipment",
style: TextStyle(color: ThemeConfig.textColorGrey),
),
)
: GridView.builder(
padding: gridPadding,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
crossAxisSpacing: 8.0,
mainAxisSpacing: 8.0,
childAspectRatio: childAspectRatio,
),
itemCount: itemCount,
itemBuilder: (context, index) {
if (index < items.length) {
final item = items[index];
return InkWell(
onTap: () {
if (mode == InventoryGridMode.equipmentSwap) {
_showEquipSlotDialog(
context,
battleProvider,
item,
);
} else {
_showItemActionDialog(
context,
battleProvider,
item,
);
}
},
child: ItemCardWidget(
item: item,
showPrice: false,
canBuy: false,
compact: mode == InventoryGridMode.equipmentSwap,
),
);
} else {
return Container(
decoration: BoxDecoration(
border: Border.all(
color: ThemeConfig.textColorGrey,
),
color: ThemeConfig.emptySlotBg,
),
child: const Center(
child: Icon(
Icons.add_box,
color: ThemeConfig.textColorGrey,
),
),
);
}
},
child: ItemCardWidget(
item: item,
// Inventory items usually don't show price unless in sell mode,
// but logic here implies standard view.
// If needed, we can toggle showPrice based on context.
showPrice: false,
canBuy: false,
),
);
} else {
return Container(
decoration: BoxDecoration(
border: Border.all(color: ThemeConfig.textColorGrey),
color: ThemeConfig.emptySlotBg,
),
child: const Center(
child: Icon(
Icons.add_box,
color: ThemeConfig.textColorGrey,
),
),
);
}
},
),
),
),
],
);
@@ -85,7 +133,11 @@ class InventoryGridWidget extends StatelessWidget {
BattleProvider provider,
Item item,
) {
bool isShop = provider.currentStage.type == StageType.shop;
final isShop =
mode == InventoryGridMode.shop ||
(mode == InventoryGridMode.normal &&
provider.currentStage.type == StageType.shop);
final isEquipmentSwap = mode == InventoryGridMode.equipmentSwap;
int sellPrice = (item.price * GameConfig.sellPriceMultiplier).floor();
showDialog(
@@ -114,7 +166,7 @@ class InventoryGridWidget extends StatelessWidget {
SimpleDialogOption(
onPressed: () {
Navigator.pop(ctx);
_showEquipConfirmationDialog(context, provider, item);
_showEquipSlotDialog(context, provider, item);
},
child: const Padding(
padding: EdgeInsets.symmetric(vertical: 8.0),
@@ -147,27 +199,84 @@ class InventoryGridWidget extends StatelessWidget {
),
),
),
SimpleDialogOption(
onPressed: () {
Navigator.pop(ctx);
_showDiscardConfirmationDialog(context, provider, item);
},
child: const Padding(
padding: EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
Icon(Icons.delete, color: ThemeConfig.btnActionActive),
SizedBox(width: 10),
Text(AppStrings.discard),
],
if (!isEquipmentSwap)
SimpleDialogOption(
onPressed: () {
Navigator.pop(ctx);
_showDiscardConfirmationDialog(context, provider, item);
},
child: const Padding(
padding: EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
Icon(Icons.delete, color: ThemeConfig.btnActionActive),
SizedBox(width: 10),
Text(AppStrings.discard),
],
),
),
),
),
],
),
);
}
void _showEquipSlotDialog(
BuildContext context,
BattleProvider provider,
Item newItem,
) {
final compatibleSlots = newItem.compatibleEquipSlots;
if (compatibleSlots.isEmpty) return;
if (compatibleSlots.length == 1) {
_showEquipConfirmationDialog(
context,
provider,
newItem,
compatibleSlots.first,
);
return;
}
showDialog(
context: context,
builder: (ctx) => SimpleDialog(
title: Text("Equip ${newItem.name}"),
children: compatibleSlots
.map(
(slot) => SimpleDialogOption(
onPressed: () {
Navigator.pop(ctx);
_showEquipConfirmationDialog(
context,
provider,
newItem,
slot,
);
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
Image.asset(
ItemUtils.getIconPath(slot),
width: ThemeConfig.itemIconSizeMedium,
height: ThemeConfig.itemIconSizeMedium,
color: ThemeConfig.textColorWhite,
),
const SizedBox(width: 10),
Text(ItemUtils.getSlotName(slot)),
],
),
),
),
)
.toList(),
),
);
}
void _showSellConfirmationDialog(
BuildContext context,
BattleProvider provider,
@@ -237,9 +346,10 @@ class InventoryGridWidget extends StatelessWidget {
BuildContext context,
BattleProvider provider,
Item newItem,
EquipmentSlot targetSlot,
) {
final player = provider.player;
final oldItem = player.equipment[newItem.slot];
final oldItem = player.equipment[targetSlot];
final currentMaxHp = player.totalMaxHp;
final currentAtk = player.totalAtk;
@@ -263,7 +373,7 @@ class InventoryGridWidget extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Text(
"${AppStrings.equip} ${newItem.name}?",
"${AppStrings.equip} ${newItem.name} as ${ItemUtils.getSlotName(targetSlot)}?",
style: const TextStyle(fontWeight: ThemeConfig.fontWeightBold),
),
if (oldItem != null)
@@ -289,6 +399,25 @@ class InventoryGridWidget extends StatelessWidget {
player.totalDodge,
player.totalDodge - (oldItem?.dodge ?? 0) + newItem.dodge,
),
if (newItem.effects.isNotEmpty || (oldItem != null && oldItem.effects.isNotEmpty)) ...[
const Divider(color: ThemeConfig.textColorGrey, height: 16),
if (oldItem != null && oldItem.effects.isNotEmpty)
...oldItem.effects.map((e) => Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("- ", style: TextStyle(color: ThemeConfig.textColorGrey, fontSize: 12)),
Expanded(child: Text(e.description, style: const TextStyle(color: ThemeConfig.textColorGrey, fontSize: 12))),
],
)),
if (newItem.effects.isNotEmpty)
...newItem.effects.map((e) => Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("+ ", style: TextStyle(color: ThemeConfig.rarityLegendary, fontSize: 12, fontWeight: FontWeight.bold)),
Expanded(child: Text(e.description, style: const TextStyle(color: ThemeConfig.rarityLegendary, fontSize: 12, fontWeight: FontWeight.bold))),
],
)),
],
],
),
actions: [
@@ -298,7 +427,7 @@ class InventoryGridWidget extends StatelessWidget {
),
ElevatedButton(
onPressed: () {
provider.equipItem(newItem);
provider.equipItem(newItem, targetSlot: targetSlot);
Navigator.pop(ctx);
},
child: const Text(AppStrings.confirm),