From 188a3093e63ddb1417bca33116fb00a8e3efe6b4 Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 12:58:22 +0800 Subject: [PATCH 01/37] feat(pokemon): add models, translations and plugin api types The DTOs of the plugin's JSON API (dart_mappable with a tolerant fromMap), the api base url, the exception its error envelope is reported with, and the strings of the new pages in all three locales. --- lib/constants/url.dart | 5 + lib/exceptions/exceptions.dart | 13 + lib/features/pokemon/models/adventure.dart | 147 ++++++++ lib/features/pokemon/models/battle.dart | 323 +++++++++++++++++ lib/features/pokemon/models/equipment.dart | 343 ++++++++++++++++++ .../pokemon/models/evolution_path.dart | 85 +++++ .../pokemon/models/global_config.dart | 70 ++++ .../pokemon/models/inventory_item.dart | 105 ++++++ lib/features/pokemon/models/models.dart | 15 + lib/features/pokemon/models/pokemon.dart | 305 ++++++++++++++++ lib/features/pokemon/models/shop_item.dart | 170 +++++++++ lib/features/pokemon/models/shop_pet.dart | 163 +++++++++ lib/features/pokemon/models/skill.dart | 96 +++++ lib/features/pokemon/models/user_profile.dart | 115 ++++++ lib/i18n/en.i18n.json | 177 +++++++++ lib/i18n/zh-CN.i18n.json | 177 +++++++++ lib/i18n/zh-TW.i18n.json | 177 +++++++++ 17 files changed, 2486 insertions(+) create mode 100644 lib/features/pokemon/models/adventure.dart create mode 100644 lib/features/pokemon/models/battle.dart create mode 100644 lib/features/pokemon/models/equipment.dart create mode 100644 lib/features/pokemon/models/evolution_path.dart create mode 100644 lib/features/pokemon/models/global_config.dart create mode 100644 lib/features/pokemon/models/inventory_item.dart create mode 100644 lib/features/pokemon/models/models.dart create mode 100644 lib/features/pokemon/models/pokemon.dart create mode 100644 lib/features/pokemon/models/shop_item.dart create mode 100644 lib/features/pokemon/models/shop_pet.dart create mode 100644 lib/features/pokemon/models/skill.dart create mode 100644 lib/features/pokemon/models/user_profile.dart diff --git a/lib/constants/url.dart b/lib/constants/url.dart index b493ef4d..abd420f8 100644 --- a/lib/constants/url.dart +++ b/lib/constants/url.dart @@ -77,6 +77,11 @@ const broadcastMessageUrl = '$baseUrl/home.php?mod=space&do=pm&filter=announcepm /// Broadcast message detail page. const broadcastMessageDetailUrl = '$baseUrl/home.php?mod=space&do=pm&subop=viewg&pmid='; +/// Base url of the pokemon (宠物中心) plugin JSON API. +/// +/// Requests add `&endpoint=&action=` to this base, e.g. `&endpoint=pokemon&action=list`. +const pokemonApiBase = '$baseUrl/plugin.php?id=pokemon:pokemon'; + /// The Discuz! built-in guide index page (`forum.php?mod=guide&view=index`). /// /// Lists the four guide modules 最新热门 (hot), 最新精华 (digest), 最新回复 (new) and 最新发表 (newthread) with a few diff --git a/lib/exceptions/exceptions.dart b/lib/exceptions/exceptions.dart index 04101105..14a4db62 100644 --- a/lib/exceptions/exceptions.dart +++ b/lib/exceptions/exceptions.dart @@ -479,3 +479,16 @@ final class AntitheftChallengedRequestException extends AppException with Antith /// Url of the challenged request. final String url; } + +/// The pokemon plugin JSON API answered an error envelope (`success: false`). +/// +/// [message] carries the human-readable `error` field, [code] the optional numeric `code` field (e.g. 401 when a login +/// is required, 400 for a rejected action). +@MappableClass() +final class PokemonApiException extends AppException with PokemonApiExceptionMappable { + /// Constructor. + PokemonApiException(String message, {this.code}) : super(message: message); + + /// Optional error code from the plugin API. + final int? code; +} diff --git a/lib/features/pokemon/models/adventure.dart b/lib/features/pokemon/models/adventure.dart new file mode 100644 index 00000000..e5d820d8 --- /dev/null +++ b/lib/features/pokemon/models/adventure.dart @@ -0,0 +1,147 @@ +part of 'models.dart'; + +/// A map (冒险地图) the player can adventure in. +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class AdventureMap with AdventureMapMappable { + /// Constructor. + const AdventureMap({ + required this.id, + required this.name, + required this.areaType, + required this.areaTypeName, + required this.region, + required this.posX, + required this.posY, + required this.isEnabled, + required this.minLevel, + required this.maxLevel, + required this.mode, + required this.wildPokemons, + this.bosses, + }); + + /// Tolerant decode. + factory AdventureMap.fromMap(Map map) => AdventureMap( + id: (map['id'] as num?)?.toInt() ?? 0, + name: map['name'] as String? ?? '', + areaType: map['area_type'] as String? ?? '', + areaTypeName: map['area_type_name'] as String? ?? '', + region: map['region'] as String? ?? '', + posX: (map['pos_x'] as num?)?.toInt() ?? 0, + posY: (map['pos_y'] as num?)?.toInt() ?? 0, + isEnabled: map['is_enabled'] == true, + minLevel: (map['min_level'] as num?)?.toInt() ?? 0, + maxLevel: (map['max_level'] as num?)?.toInt() ?? 0, + mode: map['mode'] as String? ?? 'wild', + wildPokemons: + (map['wild_pokemons'] as List?)?.whereType>().map(AdventureWildPokemon.fromMap).toList() ?? + const [], + bosses: (map['bosses'] as List?)?.whereType>().map(AdventureBoss.fromMap).toList(), + ); + + /// Map id (`pm_map.id`). + final int id; + + /// Map name. + final String name; + + /// Area type letter (l/g/p/s/...). + final String areaType; + + /// Human-readable area type name. + final String areaTypeName; + + /// Region name (中央/北部/海洋/...). + final String region; + + /// Horizontal position on the region map. + final int posX; + + /// Vertical position on the region map. + final int posY; + + /// Whether the map is enabled. + final bool isEnabled; + + /// Minimum wild level. + final int minLevel; + + /// Maximum wild level. + final int maxLevel; + + /// Map mode (`wild` or `boss`). + final String mode; + + /// Wild pokemon that appear here. + final List wildPokemons; + + /// Boss list, only present in `boss` mode. + final List? bosses; + + /// Whether the map has a boss challenge. + bool get hasBoss => bosses?.isNotEmpty ?? false; +} + +/// A wild pokemon that appears on a map. +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class AdventureWildPokemon with AdventureWildPokemonMappable { + /// Constructor. + const AdventureWildPokemon({required this.id, required this.name}); + + /// Tolerant decode. + factory AdventureWildPokemon.fromMap(Map map) => AdventureWildPokemon( + id: (map['id'] as num?)?.toInt() ?? 0, + name: map['name'] as String? ?? '', + ); + + /// Species id (`pm_data.id`). + final int id; + + /// Species name. + final String name; +} + +/// A boss on a map. +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class AdventureBoss with AdventureBossMappable { + /// Constructor. + const AdventureBoss({ + required this.pokemonTypeId, + required this.pokemonName, + required this.level, + required this.bossMultiplier, + }); + + /// Tolerant decode. + factory AdventureBoss.fromMap(Map map) => AdventureBoss( + pokemonTypeId: (map['pokemon_type_id'] as num?)?.toInt() ?? 0, + pokemonName: map['pokemon_name'] as String? ?? '', + level: (map['level'] as num?)?.toInt() ?? 0, + bossMultiplier: (map['boss_multiplier'] as num?)?.toDouble() ?? 1.0, + ); + + /// Species id (`pm_data.id`) of the boss. + final int pokemonTypeId; + + /// Boss name. + final String pokemonName; + + /// Boss level. + final int level; + + /// Stat multiplier. + final double bossMultiplier; +} + +/// The maps list response. +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class MapsResponse with MapsResponseMappable { + /// Constructor. + const MapsResponse({required this.maps, required this.total}); + + /// Maps. + final List maps; + + /// Total number of maps. + final int total; +} diff --git a/lib/features/pokemon/models/battle.dart b/lib/features/pokemon/models/battle.dart new file mode 100644 index 00000000..c6bf551d --- /dev/null +++ b/lib/features/pokemon/models/battle.dart @@ -0,0 +1,323 @@ +part of 'models.dart'; + +/// A battle in progress, or its result after a turn/action. +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class BattleScene with BattleSceneMappable { + /// Constructor. + const BattleScene({ + required this.battleId, + required this.mapId, + required this.mapName, + required this.turn, + required this.myPokemon, + required this.wildPokemon, + required this.status, + this.message, + this.rewards, + this.levelUp, + }); + + /// Tolerant decode: every field is optional, missing values fall back to a safe default. + factory BattleScene.fromMap(Map map) => BattleScene( + battleId: map['battle_id'] as String? ?? '', + mapId: (map['map_id'] as num?)?.toInt() ?? 0, + mapName: map['map_name'] as String? ?? '', + turn: (map['turn'] as num?)?.toInt() ?? 0, + myPokemon: BattlePokemon.fromMap(map['my_pokemon'] as Map? ?? const {}), + wildPokemon: WildPokemon.fromMap(map['wild_pokemon'] as Map? ?? const {}), + status: map['status'] as String? ?? 'active', + message: map['message'] as String?, + rewards: map['rewards'] == null ? null : BattleRewards.fromMap(map['rewards'] as Map), + levelUp: map['level_up'] == null ? null : LevelUpInfo.fromMap(map['level_up'] as Map), + ); + + /// Battle id. + final String battleId; + + /// Map id. + final int mapId; + + /// Map name. + final String mapName; + + /// Current turn number. + final int turn; + + /// The player's pokemon. + final BattlePokemon myPokemon; + + /// The wild pokemon or boss. + final WildPokemon wildPokemon; + + /// Battle status: `active`/`victory`/`defeat`/`fled`/`captured`. + final String status; + + /// Latest battle message; absent in some responses (e.g. recover). + final String? message; + + /// Rewards, when the battle ends in victory/capture. + final BattleRewards? rewards; + + /// Level-up info, when the pokemon leveled up. + final LevelUpInfo? levelUp; + + /// Whether the battle is still ongoing. + bool get isActive => status == 'active'; +} + +/// The player's pokemon during a battle. +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class BattlePokemon with BattlePokemonMappable { + /// Constructor. + const BattlePokemon({ + required this.id, + required this.instanceId, + required this.level, + required this.hp, + required this.maxHp, + required this.isShiny, + required this.skills, + this.name, + this.quality, + }); + + /// Tolerant decode. + factory BattlePokemon.fromMap(Map map) => BattlePokemon( + id: (map['id'] as num?)?.toInt() ?? 0, + instanceId: (map['instance_id'] as num?)?.toInt() ?? 0, + level: (map['level'] as num?)?.toInt() ?? 0, + hp: (map['hp'] as num?)?.toInt() ?? 0, + maxHp: (map['max_hp'] as num?)?.toInt() ?? 0, + isShiny: map['is_shiny'] == true, + skills: (map['skills'] as List?)?.whereType>().map(BattleSkill.fromMap).toList() ?? const [], + name: map['name'] as String?, + quality: map['quality'] as String?, + ); + + /// Species id. + final int id; + + /// Instance id. + final int instanceId; + + /// Name; null for a pokemon without a nickname (plugin `build_battle_response` bug). + final String? name; + + /// Level. + final int level; + + /// Current HP. + final int hp; + + /// Maximum HP. + final int maxHp; + + /// Whether shiny. + final bool isShiny; + + /// Quality grade. + final String? quality; + + /// Battle skills. + final List skills; +} + +/// A skill usable during battle. +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class BattleSkill with BattleSkillMappable { + /// Constructor. + const BattleSkill({ + required this.id, + required this.power, + required this.pp, + required this.maxPp, + required this.skillType, + required this.category, + this.name, + }); + + /// Tolerant decode. + factory BattleSkill.fromMap(Map map) => BattleSkill( + id: (map['id'] as num?)?.toInt() ?? 0, + power: (map['power'] as num?)?.toInt() ?? 0, + pp: (map['pp'] as num?)?.toInt() ?? 0, + maxPp: (map['max_pp'] as num?)?.toInt() ?? 0, + skillType: map['skill_type'] as String? ?? '', + category: map['category'] as String? ?? '', + name: map['name'] as String?, + ); + + /// Skill id. + final int id; + + /// Skill name; null when the joined skill row is missing. + final String? name; + + /// Power. + final int power; + + /// Current PP. + final int pp; + + /// Maximum PP. + final int maxPp; + + /// Skill type. + final String skillType; + + /// Category (物攻/特攻 ...). + final String category; + + /// Whether the skill has PP left. + bool get usable => pp > 0; +} + +/// The wild pokemon or boss being fought. +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class WildPokemon with WildPokemonMappable { + /// Constructor. + const WildPokemon({ + required this.id, + required this.level, + required this.hp, + required this.maxHp, + required this.gender, + required this.isShiny, + this.name, + this.isBoss, + this.bossMultiplier, + }); + + /// Tolerant decode. + factory WildPokemon.fromMap(Map map) => WildPokemon( + id: (map['id'] as num?)?.toInt() ?? 0, + level: (map['level'] as num?)?.toInt() ?? 0, + hp: (map['hp'] as num?)?.toInt() ?? 0, + maxHp: (map['max_hp'] as num?)?.toInt() ?? 0, + gender: (map['gender'] as num?)?.toInt() ?? 0, + isShiny: map['is_shiny'] == true, + name: map['name'] as String?, + isBoss: map['is_boss'] as bool?, + bossMultiplier: (map['boss_multiplier'] as num?)?.toDouble(), + ); + + /// Species id. + final int id; + + /// Name; null when the species row is missing. + final String? name; + + /// Level. + final int level; + + /// Current HP. + final int hp; + + /// Maximum HP. + final int maxHp; + + /// Gender. + final int gender; + + /// Whether shiny. + final bool isShiny; + + /// Whether a boss; absent in the idle base shape. + final bool? isBoss; + + /// Stat multiplier; only present for bosses. + final double? bossMultiplier; +} + +/// Battle rewards. +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class BattleRewards with BattleRewardsMappable { + /// Constructor. + const BattleRewards({required this.exp, required this.money}); + + /// Tolerant decode. + factory BattleRewards.fromMap(Map map) => BattleRewards( + exp: (map['exp'] as num?)?.toInt() ?? 0, + money: (map['money'] as num?)?.toInt() ?? 0, + ); + + /// Experience gained. + final int exp; + + /// Money gained. + final int money; +} + +/// Level-up information. +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class LevelUpInfo with LevelUpInfoMappable { + /// Constructor. + const LevelUpInfo({required this.levelUp, required this.oldLevel, required this.newLevel, required this.newExp}); + + /// Tolerant decode. + factory LevelUpInfo.fromMap(Map map) => LevelUpInfo( + levelUp: map['level_up'] == true, + oldLevel: (map['old_level'] as num?)?.toInt() ?? 0, + newLevel: (map['new_level'] as num?)?.toInt() ?? 0, + newExp: (map['new_exp'] as num?)?.toInt() ?? 0, + ); + + /// Whether the pokemon leveled up. + final bool levelUp; + + /// Previous level. + final int oldLevel; + + /// New level. + final int newLevel; + + /// New experience total. + final int newExp; +} + +/// An item usable during battle (HP/PP restore), from `battle/get_battle_items`. +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class BattleItem with BattleItemMappable { + /// Constructor. + const BattleItem({ + required this.id, + required this.name, + required this.img, + required this.nums, + required this.itemType, + required this.module, + this.addhp, + }); + + /// Tolerant decode. + factory BattleItem.fromMap(Map map) => BattleItem( + id: (map['id'] as num?)?.toInt() ?? 0, + name: map['name'] as String? ?? '', + img: map['img'] as String? ?? '', + nums: (map['nums'] as num?)?.toInt() ?? 0, + itemType: (map['item_type'] as num?)?.toInt() ?? 0, + module: map['module'] as String? ?? '', + addhp: (map['addhp'] as num?)?.toInt(), + ); + + /// Item type id (`pm_itemdata.id`). + final int id; + + /// Item name. + final String name; + + /// Icon filename. + final String img; + + /// Owned quantity. + final int nums; + + /// Item category (1=HP药水, 4=PP恢复). + final int itemType; + + /// Effect module (`pp5`/`pp10`/...). + final String module; + + /// HP restored (HP potions only). + final int? addhp; +} diff --git a/lib/features/pokemon/models/equipment.dart b/lib/features/pokemon/models/equipment.dart new file mode 100644 index 00000000..37c1cead --- /dev/null +++ b/lib/features/pokemon/models/equipment.dart @@ -0,0 +1,343 @@ +part of 'models.dart'; + +/// Common equipment bonus values shared by owned and shop equipment. +mixin EquipmentBonus { + /// Bonus HP. + int get equipmentHp; + + /// Bonus attack. + int get equipmentAtk; + + /// Bonus defense. + int get equipmentDef; + + /// Bonus special attack. + int get equipmentSpatk; + + /// Bonus special defense. + int get equipmentSpdef; + + /// Bonus speed. + int get equipmentSd; + + /// Whether every bonus is zero. + bool get hasNoBonus => + equipmentHp == 0 && + equipmentAtk == 0 && + equipmentDef == 0 && + equipmentSpatk == 0 && + equipmentSpdef == 0 && + equipmentSd == 0; +} + +/// One equipment worn by a pokemon (`equipment_slots[].item`). +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class EquipmentItem with EquipmentItemMappable, EquipmentBonus { + /// Constructor. + const EquipmentItem({ + required this.myitemId, + required this.typeId, + required this.name, + required this.description, + required this.image, + required this.zbtype, + required this.equipmentHp, + required this.equipmentAtk, + required this.equipmentDef, + required this.equipmentSpatk, + required this.equipmentSpdef, + required this.equipmentSd, + }); + + /// Tolerant decode. + factory EquipmentItem.fromMap(Map map) => EquipmentItem( + myitemId: (map['myitem_id'] as num?)?.toInt() ?? 0, + typeId: (map['type_id'] as num?)?.toInt() ?? 0, + name: map['name'] as String? ?? '', + description: map['description'] as String? ?? '', + image: map['image'] as String? ?? '', + zbtype: (map['zbtype'] as num?)?.toInt() ?? 0, + equipmentHp: (map['equipment_hp'] as num?)?.toInt() ?? 0, + equipmentAtk: (map['equipment_atk'] as num?)?.toInt() ?? 0, + equipmentDef: (map['equipment_def'] as num?)?.toInt() ?? 0, + equipmentSpatk: (map['equipment_spatk'] as num?)?.toInt() ?? 0, + equipmentSpdef: (map['equipment_spdef'] as num?)?.toInt() ?? 0, + equipmentSd: (map['equipment_sd'] as num?)?.toInt() ?? 0, + ); + + /// Owned item row id (`pm_myitem.id`). + final int myitemId; + + /// Equipment type id (`pm_itemdata.id`). + final int typeId; + + /// Display name. + final String name; + + /// Description. + final String description; + + /// Image filename. + final String image; + + /// Equipment slot category. + final int zbtype; + + @override + final int equipmentHp; + + @override + final int equipmentAtk; + + @override + final int equipmentDef; + + @override + final int equipmentSpatk; + + @override + final int equipmentSpdef; + + @override + final int equipmentSd; +} + +/// One of the four equipment slots of a pokemon. +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class EquipmentSlot with EquipmentSlotMappable { + /// Constructor. + const EquipmentSlot({required this.slotIndex, required this.equipmentId, this.item}); + + /// Tolerant decode. + factory EquipmentSlot.fromMap(Map map) => EquipmentSlot( + slotIndex: (map['slot_index'] as num?)?.toInt() ?? 0, + equipmentId: (map['equipment_id'] as num?)?.toInt() ?? 0, + item: map['item'] == null ? null : EquipmentItem.fromMap(map['item'] as Map), + ); + + /// Slot index, 0-3. + final int slotIndex; + + /// `pm_myitem.id` currently in the slot, 0 when empty. + final int equipmentId; + + /// The worn equipment, when the slot is not empty. + final EquipmentItem? item; +} + +/// An equipment the user owns (`owned_items`). +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class OwnedEquipment with OwnedEquipmentMappable, EquipmentBonus { + /// Constructor. + const OwnedEquipment({ + required this.myitemId, + required this.typeId, + required this.name, + required this.description, + required this.image, + required this.quantity, + required this.equippedCount, + required this.availableCount, + required this.isEquipped, + required this.zbtype, + required this.equipmentHp, + required this.equipmentAtk, + required this.equipmentDef, + required this.equipmentSpatk, + required this.equipmentSpdef, + required this.equipmentSd, + }); + + /// Tolerant decode. + factory OwnedEquipment.fromMap(Map map) => OwnedEquipment( + myitemId: (map['myitem_id'] as num?)?.toInt() ?? 0, + typeId: (map['type_id'] as num?)?.toInt() ?? 0, + name: map['name'] as String? ?? '', + description: map['description'] as String? ?? '', + image: map['image'] as String? ?? '', + quantity: (map['quantity'] as num?)?.toInt() ?? 0, + equippedCount: (map['equipped_count'] as num?)?.toInt() ?? 0, + availableCount: (map['available_count'] as num?)?.toInt() ?? 0, + isEquipped: map['is_equipped'] == true, + zbtype: (map['zbtype'] as num?)?.toInt() ?? 0, + equipmentHp: (map['equipment_hp'] as num?)?.toInt() ?? 0, + equipmentAtk: (map['equipment_atk'] as num?)?.toInt() ?? 0, + equipmentDef: (map['equipment_def'] as num?)?.toInt() ?? 0, + equipmentSpatk: (map['equipment_spatk'] as num?)?.toInt() ?? 0, + equipmentSpdef: (map['equipment_spdef'] as num?)?.toInt() ?? 0, + equipmentSd: (map['equipment_sd'] as num?)?.toInt() ?? 0, + ); + + /// Owned item row id (`pm_myitem.id`). + final int myitemId; + + /// Equipment type id (`pm_itemdata.id`). + final int typeId; + + /// Display name. + final String name; + + /// Description. + final String description; + + /// Image filename. + final String image; + + /// Total quantity owned. + final int quantity; + + /// How many copies are currently worn by any pokemon. + final int equippedCount; + + /// How many copies are free to equip. + final int availableCount; + + /// Whether this equipment is worn by the pokemon being viewed. + final bool isEquipped; + + /// Equipment slot category. + final int zbtype; + + @override + final int equipmentHp; + + @override + final int equipmentAtk; + + @override + final int equipmentDef; + + @override + final int equipmentSpatk; + + @override + final int equipmentSpdef; + + @override + final int equipmentSd; +} + +/// A purchasable equipment from the equipment shop (`shop_items`). +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class ShopEquipment with ShopEquipmentMappable, EquipmentBonus { + /// Constructor. + const ShopEquipment({ + required this.typeId, + required this.name, + required this.description, + required this.image, + required this.price, + required this.zbtype, + required this.equipmentHp, + required this.equipmentAtk, + required this.equipmentDef, + required this.equipmentSpatk, + required this.equipmentSpdef, + required this.equipmentSd, + required this.isOwned, + required this.canBuy, + }); + + /// Tolerant decode. + factory ShopEquipment.fromMap(Map map) => ShopEquipment( + typeId: (map['type_id'] as num?)?.toInt() ?? 0, + name: map['name'] as String? ?? '', + description: map['description'] as String? ?? '', + image: map['image'] as String? ?? '', + price: (map['price'] as num?)?.toInt() ?? 0, + zbtype: (map['zbtype'] as num?)?.toInt() ?? 0, + equipmentHp: (map['equipment_hp'] as num?)?.toInt() ?? 0, + equipmentAtk: (map['equipment_atk'] as num?)?.toInt() ?? 0, + equipmentDef: (map['equipment_def'] as num?)?.toInt() ?? 0, + equipmentSpatk: (map['equipment_spatk'] as num?)?.toInt() ?? 0, + equipmentSpdef: (map['equipment_spdef'] as num?)?.toInt() ?? 0, + equipmentSd: (map['equipment_sd'] as num?)?.toInt() ?? 0, + isOwned: map['is_owned'] == true, + canBuy: map['can_buy'] == true, + ); + + /// Equipment type id (`pm_itemdata.id`), used as the shop `item_id` when buying. + final int typeId; + + /// Display name. + final String name; + + /// Description. + final String description; + + /// Image filename. + final String image; + + /// Price in pet money. + final int price; + + /// Equipment slot category. + final int zbtype; + + @override + final int equipmentHp; + + @override + final int equipmentAtk; + + @override + final int equipmentDef; + + @override + final int equipmentSpatk; + + @override + final int equipmentSpdef; + + @override + final int equipmentSd; + + /// Whether the user already owns at least one. + final bool isOwned; + + /// Whether the user has enough money to buy it. + final bool canBuy; +} + +/// The answer of `pokemon&action=equipment`. +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class EquipmentResponse with EquipmentResponseMappable { + /// Constructor. + const EquipmentResponse({ + required this.pokemonId, + required this.equipmentSlots, + required this.ownedItems, + required this.shopItems, + required this.userMoney, + }); + + /// Tolerant decode. + factory EquipmentResponse.fromMap(Map map) => EquipmentResponse( + pokemonId: (map['pokemon_id'] as num?)?.toInt() ?? 0, + equipmentSlots: _listOf2(map['equipment_slots'], EquipmentSlot.fromMap), + ownedItems: _listOf2(map['owned_items'], OwnedEquipment.fromMap), + shopItems: _listOf2(map['shop_items'], ShopEquipment.fromMap), + userMoney: (map['user_money'] as num?)?.toInt() ?? 0, + ); + + /// Pet id. + final int pokemonId; + + /// The four slots of the pokemon. + final List equipmentSlots; + + /// Equipment the user owns. + final List ownedItems; + + /// Equipment for sale. + final List shopItems; + + /// Current pet money. + final int userMoney; +} + +/// Decode a JSON list of objects into a typed list. +List _listOf2(Object? raw, T Function(Map) decode) { + if (raw is! List) return const []; + return raw.whereType>().map(decode).toList(); +} diff --git a/lib/features/pokemon/models/evolution_path.dart b/lib/features/pokemon/models/evolution_path.dart new file mode 100644 index 00000000..75ea5c52 --- /dev/null +++ b/lib/features/pokemon/models/evolution_path.dart @@ -0,0 +1,85 @@ +part of 'models.dart'; + +/// One step of a pokemon's evolution chain, as `evolution&action=evolution_path` reports it. +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class EvolutionStep with EvolutionStepMappable { + /// Constructor. + const EvolutionStep({required this.id, required this.name, required this.type1, required this.type2, required this.conditionDisplay}); + + /// Tolerant decode. + factory EvolutionStep.fromMap(Map map) => EvolutionStep( + id: (map['id'] as num?)?.toInt() ?? 0, + name: map['name'] as String? ?? '', + type1: map['type_1'] as String? ?? '', + type2: map['type_2'] as String?, + conditionDisplay: map['condition_display'] as String? ?? '', + ); + + /// Species id of this form. + final int id; + + /// Name of this form. + final String name; + + /// Primary type of this form. + final String type1; + + /// Secondary type of this form, null when it has only one. + final String? type2; + + /// Condition to reach this form, as the server words it (empty when there is none). + final String conditionDisplay; +} + +/// The evolution chain of a pokemon: what it can become, and what it evolved from. +/// +/// Only ever read, and the website shows the same thing in its "进化路径" dialog. +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class EvolutionPath with EvolutionPathMappable { + /// Constructor. + const EvolutionPath({ + required this.pokemonId, + required this.pokemonName, + required this.pokemonType1, + required this.pokemonType2, + required this.forward, + required this.backward, + }); + + /// Tolerant decode. + factory EvolutionPath.fromMap(Map map) => EvolutionPath( + pokemonId: (map['pokemon_id'] as num?)?.toInt() ?? 0, + pokemonName: map['pokemon_name'] as String? ?? '', + pokemonType1: map['pokemon_type1'] as String? ?? '', + pokemonType2: map['pokemon_type2'] as String?, + forward: _stepsOf(map['forward']), + backward: _stepsOf(map['backward']), + ); + + /// The species the chain was asked for. + final int pokemonId; + + /// Name of that species. + final String pokemonName; + + /// Primary type of that species. + final String pokemonType1; + + /// Secondary type of that species, null when it has only one. + final String? pokemonType2; + + /// Forms this pokemon can evolve into. + final List forward; + + /// Forms this pokemon evolved from. + final List backward; + + /// Whether there is no chain to show at all. + bool get isEmpty => forward.isEmpty && backward.isEmpty; +} + +/// Decode a step list the tolerant way: a missing or mistyped list is an empty one. +List _stepsOf(Object? raw) => [ + for (final step in raw is List ? raw : const []) + if (step is Map) EvolutionStep.fromMap(step), +]; diff --git a/lib/features/pokemon/models/global_config.dart b/lib/features/pokemon/models/global_config.dart new file mode 100644 index 00000000..03f477c7 --- /dev/null +++ b/lib/features/pokemon/models/global_config.dart @@ -0,0 +1,70 @@ +/// Global configuration of the pokemon plugin (`config/global_config`). +/// +/// Values come from the `pm_config` table and are typed by their `data_type` column on the server side. Parsing is +/// deliberately tolerant because historic rows may store a boolean as the string `'1'`/`'0'` or an empty string. +final class GlobalConfig { + /// Constructor. + const GlobalConfig({ + required this.isOpen, + required this.version, + required this.annTitle, + required this.annUrl, + required this.medicalPrice, + required this.isEnableCatch, + required this.isEnableBuyPokemon, + }); + + /// Build an instance from the `data` object of the `config/global_config` response. + factory GlobalConfig.fromJson(Map json) => GlobalConfig( + isOpen: _asBool(json['is_open']), + version: _asString(json['version']), + annTitle: _asString(json['ann_title']), + annUrl: _asString(json['ann_url']), + medicalPrice: _asInt(json['medical_price']), + isEnableCatch: _asBool(json['is_enable_catch']), + isEnableBuyPokemon: _asBool(json['is_enable_buy_pokemon']), + ); + + /// Whether the plugin is open. + final bool isOpen; + + /// Plugin version string. + final String version; + + /// Announcement title. + final String annTitle; + + /// Announcement url. + final String annUrl; + + /// Price of healing at the pokemon center. + final int medicalPrice; + + /// Whether catching wild pokemon is enabled. + final bool isEnableCatch; + + /// Whether buying pokemon is enabled. + final bool isEnableBuyPokemon; + + static String _asString(Object? v) { + if (v == null) return ''; + return v.toString(); + } + + static int _asInt(Object? v) { + if (v is int) return v; + if (v is num) return v.toInt(); + if (v is String) return int.tryParse(v.trim()) ?? 0; + return 0; + } + + static bool _asBool(Object? v) { + if (v is bool) return v; + if (v is int || v is num) return v != 0; + if (v is String) { + final s = v.trim().toLowerCase(); + return s == '1' || s == 'true' || s == 'yes' || s == 'on'; + } + return false; + } +} diff --git a/lib/features/pokemon/models/inventory_item.dart b/lib/features/pokemon/models/inventory_item.dart new file mode 100644 index 00000000..2d34854b --- /dev/null +++ b/lib/features/pokemon/models/inventory_item.dart @@ -0,0 +1,105 @@ +part of 'models.dart'; + +/// One item in the current user's inventory (背包). +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class InventoryItem with InventoryItemMappable { + /// Constructor. + const InventoryItem({ + required this.id, + required this.typeId, + required this.name, + required this.description, + required this.image, + required this.quantity, + required this.nums, + required this.typeName, + required this.canUse, + required this.addhp, + this.itemType, + }); + + /// Tolerant decode: every field is optional, missing values fall back to a safe default. + factory InventoryItem.fromMap(Map map) => InventoryItem( + id: (map['id'] as num?)?.toInt() ?? 0, + typeId: (map['type_id'] as num?)?.toInt() ?? 0, + name: map['name'] as String? ?? '', + description: map['description'] as String? ?? '', + image: map['image'] as String? ?? '', + quantity: (map['quantity'] as num?)?.toInt() ?? 0, + nums: (map['nums'] as num?)?.toInt() ?? 0, + typeName: map['type_name'] as String? ?? '', + canUse: map['can_use'] == true, + addhp: (map['addhp'] as num?)?.toInt() ?? 0, + itemType: (map['item_type'] as num?)?.toInt(), + ); + + /// Item row id (`pm_myitem.id`). + final int id; + + /// Item type id (`pm_itemdata.id`). + final int typeId; + + /// Item category type. + final int? itemType; + + /// Item name. + final String name; + + /// Item description. + final String description; + + /// Icon filename (tpname). + final String image; + + /// Owned quantity. + final int quantity; + + /// Quantity usable in battle. + final int nums; + + /// Category name. + final String typeName; + + /// Whether the item can be used here. + final bool canUse; + + /// HP restored by this item (HP potions only). + final int addhp; +} + +/// A page of inventory items. +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class InventoryPage with InventoryPageMappable { + /// Constructor. + const InventoryPage({ + required this.items, + required this.total, + required this.page, + required this.perPage, + required this.totalPages, + }); + + /// Tolerant decode. + factory InventoryPage.fromMap(Map map) => InventoryPage( + items: (map['items'] as List?)?.whereType>().map(InventoryItem.fromMap).toList() ?? const [], + total: (map['total'] as num?)?.toInt() ?? 0, + page: (map['page'] as num?)?.toInt() ?? 1, + perPage: (map['per_page'] as num?)?.toInt() ?? 0, + totalPages: (map['total_pages'] as num?)?.toInt() ?? 1, + ); + + /// Items on this page. + final List items; + + /// Total item count. + final int total; + + /// Current page number. + final int page; + + /// Items per page. + final int perPage; + + /// Total number of pages. + final int totalPages; +} diff --git a/lib/features/pokemon/models/models.dart b/lib/features/pokemon/models/models.dart new file mode 100644 index 00000000..ee29b7b2 --- /dev/null +++ b/lib/features/pokemon/models/models.dart @@ -0,0 +1,15 @@ +import 'package:dart_mappable/dart_mappable.dart'; + +export 'global_config.dart'; + +part 'adventure.dart'; +part 'battle.dart'; +part 'equipment.dart'; +part 'evolution_path.dart'; +part 'inventory_item.dart'; +part 'pokemon.dart'; +part 'shop_item.dart'; +part 'shop_pet.dart'; +part 'skill.dart'; +part 'user_profile.dart'; +part 'models.mapper.dart'; diff --git a/lib/features/pokemon/models/pokemon.dart b/lib/features/pokemon/models/pokemon.dart new file mode 100644 index 00000000..ba7cb0a7 --- /dev/null +++ b/lib/features/pokemon/models/pokemon.dart @@ -0,0 +1,305 @@ +part of 'models.dart'; + +/// A single pokemon owned by the current user. +/// +/// The `list` action returns the same shape minus [stats]; the `detail` action additionally carries [stats]. +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class Pokemon with PokemonMappable { + /// Constructor. + const Pokemon({ + required this.id, + required this.name, + required this.typeId, + required this.level, + required this.exp, + required this.expToNextLevel, + required this.expForCurrentLevel, + required this.expForNextLevel, + required this.hp, + required this.maxHp, + required this.gender, + required this.isShiny, + required this.site, + required this.state, + required this.stateText, + required this.stateClass, + required this.affection, + required this.skills, + required this.baseInfo, + this.nickname, + this.quality, + this.stats, + }); + + /// Tolerant decode: every field is optional, missing values fall back to a safe default. + factory Pokemon.fromMap(Map map) => Pokemon( + id: (map['id'] as num?)?.toInt() ?? 0, + name: map['name'] as String? ?? '', + typeId: (map['type_id'] as num?)?.toInt() ?? 0, + level: (map['level'] as num?)?.toInt() ?? 0, + exp: (map['exp'] as num?)?.toInt() ?? 0, + expToNextLevel: (map['exp_to_next_level'] as num?)?.toInt() ?? 0, + expForCurrentLevel: (map['exp_for_current_level'] as num?)?.toInt() ?? 0, + expForNextLevel: (map['exp_for_next_level'] as num?)?.toInt() ?? 0, + hp: (map['hp'] as num?)?.toInt() ?? 0, + maxHp: (map['max_hp'] as num?)?.toInt() ?? 0, + gender: (map['gender'] as num?)?.toInt() ?? 0, + isShiny: map['is_shiny'] == true, + site: (map['site'] as num?)?.toInt() ?? 0, + state: (map['state'] as num?)?.toInt() ?? 0, + stateText: map['state_text'] as String? ?? '', + stateClass: map['state_class'] as String? ?? '', + affection: (map['affection'] as num?)?.toInt() ?? 0, + skills: (map['skills'] as List?)?.whereType>().map(PokemonSkill.fromMap).toList() ?? const [], + baseInfo: PokemonBaseInfo.fromMap(map['base_info'] as Map? ?? const {}), + nickname: map['nickname'] as String?, + quality: map['quality'] as String?, + stats: map['stats'] == null ? null : PokemonStats.fromMap(map['stats'] as Map), + ); + + /// Unique pet id (the `pm_mypm.id` row id). + final int id; + + /// Species name (原始名称). + final String name; + + /// User-given nickname, when set. + final String? nickname; + + /// Base form id (`pm_data.id`). + final int typeId; + + /// Current level. + final int level; + + /// Current experience. + final int exp; + + /// Experience still needed to reach the next level. + final int expToNextLevel; + + /// Experience threshold at the start of the current level. + final int expForCurrentLevel; + + /// Experience threshold to reach the next level. + final int expForNextLevel; + + /// Current HP. + final int hp; + + /// Maximum HP. + final int maxHp; + + /// Gender (0=雄, 1=雌, 2=无). + final int gender; + + /// Whether the pokemon is shiny (闪光). + final bool isShiny; + + /// Quality grade (A/B/C/D/S/SS/SS+). + final String? quality; + + /// Position (1 = first / currently battling). + final int site; + + /// State code (0=濒危, 1=正常, ...). + final int state; + + /// Human-readable state text. + final String stateText; + + /// State CSS class name. + final String stateClass; + + /// Affection (亲密度). + final int affection; + + /// Up to four skill slots. + final List skills; + + /// Base species info. + final PokemonBaseInfo baseInfo; + + /// Ability values, only present in the `detail` response. + final PokemonStats? stats; + + /// Display name: nickname when set, otherwise the species name. + String get displayName => nickname == null || nickname!.isEmpty ? name : nickname!; + + /// Progress toward the next level, in [0, 1]. + double get expProgress { + final span = expForNextLevel - expForCurrentLevel; + if (span <= 0) return 0; + return ((exp - expForCurrentLevel) / span).clamp(0, 1); + } + + /// Whether the pokemon is carried in the party instead of sitting in the storage box. + /// + /// The plugin sorts the party with `site`: 1 is the pokemon in front, 2 a reserve, 3 the storage box, and its own + /// checks ask for `site < 3` (see `api_switch_pokemon`). + bool get isCarried => site < 3; + + /// Whether the pokemon sits in the storage box. + bool get isStored => site == 3; + + /// Whether the pokemon is the one in front, i.e. the one a fight starts with. + bool get isActive => site == 1; + + /// Pokemon states the plugin treats as negative and resets to normal when it heals the pet. + /// + /// Mirrors `$negative_states` in `plugin/api/user.php`. The states outside this set (兴奋 8-10, 快乐 12-14, + /// 自恋 16-17, 愤怒 18-19) are deliberately left alone by the server heal, so they must not count as a reason to ask + /// for one either: a state the server never resets would otherwise be healed, and reported, on every single fight. + static const negativeStates = {0, 2, 3, 4, 5, 6, 7, 11, 15, 20, 21, 22}; + + /// Whether the plugin resets [state] to normal when it heals this pokemon. + bool get isNegativeState => negativeStates.contains(state); + + /// Whether this pokemon needs a trip to the pokemon center. + /// + /// True when it is carried (site 1/2) and is hurt, in a negative state, or has a skill below its maximum PP: + /// attacking spends PP even when the pet takes no damage, and the free server heal restores all three. + bool get needsHealing => + isCarried && (hp < maxHp || isNegativeState || skills.any((skill) => !skill.isEmpty && skill.pp < skill.maxPp)); +} + +/// One skill slot of a pokemon. +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class PokemonSkill with PokemonSkillMappable { + /// Constructor. + const PokemonSkill({ + required this.typeId, + required this.pp, + required this.name, + required this.skillType, + required this.category, + required this.level, + required this.power, + required this.maxPp, + }); + + /// Tolerant decode. + factory PokemonSkill.fromMap(Map map) => PokemonSkill( + typeId: (map['type_id'] as num?)?.toInt() ?? 0, + pp: (map['pp'] as num?)?.toInt() ?? 0, + name: map['name'] as String? ?? '', + skillType: map['skill_type'] as String? ?? '', + category: map['category'] as String? ?? '', + level: (map['level'] as num?)?.toInt() ?? 0, + power: (map['power'] as num?)?.toInt() ?? 0, + maxPp: (map['max_pp'] as num?)?.toInt() ?? 0, + ); + + /// Skill id (`pm_skill.id`); 0 for an empty slot. + final int typeId; + + /// Current PP. + final int pp; + + /// Skill name (empty for an empty slot). + final String name; + + /// Skill type (电/超能/普通 ...). + final String skillType; + + /// Category (物攻/特攻 ...). + final String category; + + /// Required level to learn. + final int level; + + /// Power. + final int power; + + /// Maximum PP. + final int maxPp; + + /// Whether this slot holds a real skill. + bool get isEmpty => typeId == 0; +} + +/// Base species info from the `pm_data` table. +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class PokemonBaseInfo with PokemonBaseInfoMappable { + /// Constructor. + const PokemonBaseInfo({ + required this.id, + required this.name, + required this.type1, + this.type2, + this.image, + this.description, + }); + + /// Tolerant decode. + factory PokemonBaseInfo.fromMap(Map map) => PokemonBaseInfo( + id: (map['id'] as num?)?.toInt() ?? 0, + name: map['name'] as String? ?? '', + type1: map['type_1'] as String? ?? '', + type2: map['type_2'] as String?, + image: map['image'] as String?, + description: map['description'] as String?, + ); + + /// Species id (`pm_data.id`). + final int id; + + /// Species name. + final String name; + + /// Primary type (电/水/火 ...). + @MappableField(key: 'type_1') + final String type1; + + /// Secondary type, when present. + @MappableField(key: 'type_2') + final String? type2; + + /// Image filename; empty on the server side, build the url from the species id instead. + final String? image; + + /// Pokédex description. + final String? description; +} + +/// Ability values of a pokemon. +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class PokemonStats with PokemonStatsMappable { + /// Constructor. + const PokemonStats({ + required this.hp, + required this.attack, + required this.defense, + required this.spAttack, + required this.spDefense, + required this.speed, + }); + + /// Tolerant decode. + factory PokemonStats.fromMap(Map map) => PokemonStats( + hp: (map['hp'] as num?)?.toInt() ?? 0, + attack: (map['attack'] as num?)?.toInt() ?? 0, + defense: (map['defense'] as num?)?.toInt() ?? 0, + spAttack: (map['sp_attack'] as num?)?.toInt() ?? 0, + spDefense: (map['sp_defense'] as num?)?.toInt() ?? 0, + speed: (map['speed'] as num?)?.toInt() ?? 0, + ); + + /// HP. + final int hp; + + /// Attack. + final int attack; + + /// Defense. + final int defense; + + /// Special attack. + final int spAttack; + + /// Special defense. + final int spDefense; + + /// Speed. + final int speed; +} diff --git a/lib/features/pokemon/models/shop_item.dart b/lib/features/pokemon/models/shop_item.dart new file mode 100644 index 00000000..d4506c9b --- /dev/null +++ b/lib/features/pokemon/models/shop_item.dart @@ -0,0 +1,170 @@ +part of 'models.dart'; + +/// One item sold in the shop (商店). +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class ShopItem with ShopItemMappable { + /// Constructor. + const ShopItem({ + required this.id, + required this.name, + required this.description, + required this.typeId, + required this.typeName, + required this.image, + required this.price, + required this.stock, + required this.effect, + required this.canBuy, + }); + + /// Tolerant decode: every field is optional, missing values fall back to a safe default. + factory ShopItem.fromMap(Map map) => ShopItem( + id: (map['id'] as num?)?.toInt() ?? 0, + name: map['name'] as String? ?? '', + description: map['description'] as String? ?? '', + typeId: (map['type_id'] as num?)?.toInt() ?? 0, + typeName: map['type_name'] as String? ?? '', + image: map['image'] as String? ?? '', + price: (map['price'] as num?)?.toInt() ?? 0, + stock: (map['stock'] as num?)?.toInt() ?? 0, + effect: ShopItemEffect.fromMap(map['effect'] as Map? ?? const {}), + canBuy: map['can_buy'] == true, + ); + + /// Item id. + final int id; + + /// Item name. + final String name; + + /// Item description. + final String description; + + /// Category id. + final int typeId; + + /// Category name. + final String typeName; + + /// Icon filename (tpname). + final String image; + + /// Price in in-game money. + final int price; + + /// Stock (-1 for unlimited). + final int stock; + + /// Effect description. + final ShopItemEffect effect; + + /// Whether the current user can afford it. + final bool canBuy; +} + +/// Effect of a shop item. +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class ShopItemEffect with ShopItemEffectMappable { + /// Constructor. + const ShopItemEffect({ + required this.description, + required this.effectType, + required this.addhp, + required this.addexp, + required this.addlv, + }); + + /// Tolerant decode. + factory ShopItemEffect.fromMap(Map map) => ShopItemEffect( + description: map['description'] as String? ?? '', + effectType: map['type'] as String? ?? '', + addhp: (map['addhp'] as num?)?.toInt() ?? 0, + addexp: (map['addexp'] as num?)?.toInt() ?? 0, + addlv: (map['addlv'] as num?)?.toInt() ?? 0, + ); + + /// Human-readable effect. + final String description; + + /// Effect type (heal ...). + @MappableField(key: 'type') + final String effectType; + + /// HP restored. + final int addhp; + + /// Experience gained. + final int addexp; + + /// Level gained. + final int addlv; +} + +/// A page of shop items. +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class ShopPage with ShopPageMappable { + /// Constructor. + const ShopPage({ + required this.items, + required this.total, + required this.page, + required this.perPage, + required this.totalPages, + }); + + /// Tolerant decode. + factory ShopPage.fromMap(Map map) => ShopPage( + items: (map['items'] as List?)?.whereType>().map(ShopItem.fromMap).toList() ?? const [], + total: (map['total'] as num?)?.toInt() ?? 0, + page: (map['page'] as num?)?.toInt() ?? 1, + perPage: (map['per_page'] as num?)?.toInt() ?? 0, + totalPages: (map['total_pages'] as num?)?.toInt() ?? 1, + ); + + /// Items on this page. + final List items; + + /// Total item count. + final int total; + + /// Current page number. + final int page; + + /// Items per page. + final int perPage; + + /// Total number of pages. + final int totalPages; +} + +/// Result of a successful purchase. +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class BuyResult with BuyResultMappable { + /// Constructor. + const BuyResult({ + required this.message, + required this.totalCost, + required this.remainingMoney, + required this.itemsPurchased, + }); + + /// Tolerant decode. + factory BuyResult.fromMap(Map map) => BuyResult( + message: map['message'] as String? ?? '', + totalCost: (map['total_cost'] as num?)?.toInt() ?? 0, + remainingMoney: (map['remaining_money'] as num?)?.toInt() ?? 0, + itemsPurchased: (map['items_purchased'] as num?)?.toInt() ?? 0, + ); + + /// Server message. + final String message; + + /// Total cost. + final int totalCost; + + /// Remaining money after purchase. + final int remainingMoney; + + /// Number of items purchased. + final int itemsPurchased; +} diff --git a/lib/features/pokemon/models/shop_pet.dart b/lib/features/pokemon/models/shop_pet.dart new file mode 100644 index 00000000..a95e370a --- /dev/null +++ b/lib/features/pokemon/models/shop_pet.dart @@ -0,0 +1,163 @@ +part of 'models.dart'; + +/// A pokemon for sale in the shop (`shop&action=pets`). +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class ShopPet with ShopPetMappable { + /// Constructor. + const ShopPet({ + required this.id, + required this.name, + required this.type1, + required this.hp, + required this.atk, + required this.def, + required this.spatk, + required this.spdef, + required this.speed, + required this.price, + required this.canBuy, + this.type2, + }); + + /// Tolerant decode. + factory ShopPet.fromMap(Map map) => ShopPet( + id: (map['id'] as num?)?.toInt() ?? 0, + name: map['name'] as String? ?? '', + type1: map['type_1'] as String? ?? '', + type2: map['type_2'] as String?, + hp: (map['hp'] as num?)?.toInt() ?? 0, + atk: (map['atk'] as num?)?.toInt() ?? 0, + def: (map['def'] as num?)?.toInt() ?? 0, + spatk: (map['spatk'] as num?)?.toInt() ?? 0, + spdef: (map['spdef'] as num?)?.toInt() ?? 0, + speed: (map['speed'] as num?)?.toInt() ?? 0, + price: (map['price'] as num?)?.toInt() ?? 0, + canBuy: map['can_buy'] == true, + ); + + /// Species id (`pm_data.id`). + final int id; + + /// Species name. + final String name; + + /// Primary type. + final String type1; + + /// Secondary type, when present. + final String? type2; + + /// Base HP. + final int hp; + + /// Base attack. + final int atk; + + /// Base defense. + final int def; + + /// Base special attack. + final int spatk; + + /// Base special defense. + final int spdef; + + /// Base speed. + final int speed; + + /// Price in pet money. + final int price; + + /// Whether the user has enough money to buy it. + final bool canBuy; +} + +/// A page of the pet shop. +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class ShopPetsPage with ShopPetsPageMappable { + /// Constructor. + const ShopPetsPage({ + required this.pets, + required this.total, + required this.page, + required this.perPage, + required this.totalPages, + }); + + /// Tolerant decode. + factory ShopPetsPage.fromMap(Map map) => ShopPetsPage( + pets: _listOf3(map['pets'], ShopPet.fromMap), + total: (map['total'] as num?)?.toInt() ?? 0, + page: (map['page'] as num?)?.toInt() ?? 1, + perPage: (map['per_page'] as num?)?.toInt() ?? 0, + totalPages: (map['total_pages'] as num?)?.toInt() ?? 0, + ); + + /// The pets on this page. + final List pets; + + /// Total number of pets for sale. + final int total; + + /// Current page (1-based). + final int page; + + /// Items per page. + final int perPage; + + /// Total number of pages. + final int totalPages; +} + +/// The answer of `shop&action=buy_pet`. +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class BuyPetResult with BuyPetResultMappable { + /// Constructor. + const BuyPetResult({ + required this.message, + required this.totalCost, + required this.remainingMoney, + required this.pokemonName, + required this.pokemonTypeId, + required this.site, + required this.quantity, + }); + + /// Tolerant decode. + factory BuyPetResult.fromMap(Map map) => BuyPetResult( + message: map['message'] as String? ?? '', + totalCost: (map['total_cost'] as num?)?.toInt() ?? 0, + remainingMoney: (map['remaining_money'] as num?)?.toInt() ?? 0, + pokemonName: map['pokemon_name'] as String? ?? '', + pokemonTypeId: (map['pokemon_type_id'] as num?)?.toInt() ?? 0, + site: (map['site'] as num?)?.toInt() ?? 0, + quantity: (map['quantity'] as num?)?.toInt() ?? 0, + ); + + /// Server message. + final String message; + + /// Total amount spent. + final int totalCost; + + /// Money left after the purchase. + final int remainingMoney; + + /// Name of the bought species. + final String pokemonName; + + /// Bought species id. + final int pokemonTypeId; + + /// Where the new pet was placed (1=first, 2=bag, 3=storage). + final int site; + + /// Number bought. + final int quantity; +} + +/// Decode a JSON list of objects into a typed list. +List _listOf3(Object? raw, T Function(Map) decode) { + if (raw is! List) return const []; + return raw.whereType>().map(decode).toList(); +} diff --git a/lib/features/pokemon/models/skill.dart b/lib/features/pokemon/models/skill.dart new file mode 100644 index 00000000..50513b4d --- /dev/null +++ b/lib/features/pokemon/models/skill.dart @@ -0,0 +1,96 @@ +part of 'models.dart'; + +/// A skill a pokemon may learn, from `pokemon&action=learnable_skills`. +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class LearnableSkill with LearnableSkillMappable { + /// Constructor. + const LearnableSkill({ + required this.id, + required this.name, + required this.description, + required this.type, + required this.category, + required this.power, + required this.maxPp, + required this.requiredLevel, + required this.isAvailable, + }); + + /// Tolerant decode. + factory LearnableSkill.fromMap(Map map) => LearnableSkill( + id: (map['id'] as num?)?.toInt() ?? 0, + name: map['name'] as String? ?? '', + description: map['description'] as String? ?? '', + type: map['type'] as String? ?? '', + category: map['category'] as String? ?? '', + power: (map['power'] as num?)?.toInt() ?? 0, + maxPp: (map['max_pp'] as num?)?.toInt() ?? 0, + requiredLevel: (map['required_level'] as num?)?.toInt() ?? 0, + isAvailable: map['is_available'] == true, + ); + + /// Skill id (`pm_skill.id`). + final int id; + + /// Skill name. + final String name; + + /// Skill description. + final String description; + + /// Skill type (电/超能/普通 ...). + final String type; + + /// Category (物攻/特攻 ...). + final String category; + + /// Power. + final int power; + + /// Maximum PP. + final int maxPp; + + /// Level the pokemon must reach to learn this skill. + final int requiredLevel; + + /// Whether the pokemon's current level already allows learning it. + final bool isAvailable; +} + +/// The answer of `pokemon&action=learnable_skills`: what one pokemon can still learn. +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class LearnableSkills with LearnableSkillsMappable { + /// Constructor. + const LearnableSkills({ + required this.pokemonId, + required this.pokemonLevel, + required this.availableSkills, + required this.unlockedSkills, + }); + + /// Tolerant decode. + factory LearnableSkills.fromMap(Map map) => LearnableSkills( + pokemonId: (map['pokemon_id'] as num?)?.toInt() ?? 0, + pokemonLevel: (map['pokemon_level'] as num?)?.toInt() ?? 0, + availableSkills: _learnableSkillList(map['available_skills']), + unlockedSkills: _learnableSkillList(map['unlocked_skills']), + ); + + /// Pet id. + final int pokemonId; + + /// Current level of the pet. + final int pokemonLevel; + + /// Skills whose level requirement is already met. + final List availableSkills; + + /// Skills that still need a higher level. + final List unlockedSkills; +} + +/// Decode a JSON list of learnable skills. +List _learnableSkillList(Object? raw) { + if (raw is! List) return const []; + return raw.whereType>().map(LearnableSkill.fromMap).toList(); +} diff --git a/lib/features/pokemon/models/user_profile.dart b/lib/features/pokemon/models/user_profile.dart new file mode 100644 index 00000000..9610b4e1 --- /dev/null +++ b/lib/features/pokemon/models/user_profile.dart @@ -0,0 +1,115 @@ +part of 'models.dart'; + +/// Profile of the current user inside the pokemon plugin. +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class PokemonUserProfile with PokemonUserProfileMappable { + /// Constructor. + const PokemonUserProfile({ + required this.uid, + required this.username, + this.groupId, + this.isAdmin, + this.isNewPlayer, + this.money, + this.adventureStrength, + this.strengthLevel, + this.wins, + this.losses, + this.totalBattles, + this.totalPokemons, + this.totalItems, + this.npcid, + this.statusBarHidden, + }); + + /// Tolerant decode: every field is optional, missing values fall back to null. + factory PokemonUserProfile.fromMap(Map map) => PokemonUserProfile( + uid: (map['uid'] as num?)?.toInt() ?? 0, + username: map['username'] as String? ?? '', + groupId: (map['group_id'] as num?)?.toInt(), + isAdmin: map['is_admin'] as bool?, + isNewPlayer: map['is_new_player'] as bool?, + money: (map['money'] as num?)?.toInt(), + adventureStrength: (map['adventure_strength'] as num?)?.toInt(), + strengthLevel: (map['strength_level'] as num?)?.toInt(), + wins: (map['wins'] as num?)?.toInt(), + losses: (map['losses'] as num?)?.toInt(), + totalBattles: (map['total_battles'] as num?)?.toInt(), + totalPokemons: (map['total_pokemons'] as num?)?.toInt(), + totalItems: (map['total_items'] as num?)?.toInt(), + npcid: (map['npcid'] as num?)?.toInt(), + statusBarHidden: map['status_bar_hidden'] as bool?, + ); + + /// Forum user id. + final int uid; + + /// Forum username. + final String username; + + /// User group id. + final int? groupId; + + /// Whether the user is a plugin admin. + final bool? isAdmin; + + /// Whether the user has not started the plugin yet. + final bool? isNewPlayer; + + /// In-game money (宠物币); null for a new player. + final int? money; + + /// Adventure strength. + final int? adventureStrength; + + /// Adventure strength level. + final int? strengthLevel; + + /// Win count. + final int? wins; + + /// Loss count. + final int? losses; + + /// Total battles. + final int? totalBattles; + + /// Owned pokemon count. + final int? totalPokemons; + + /// Owned item count. + final int? totalItems; + + /// Pokemon currently in battle (0 = none). + final int? npcid; + + /// Whether the status bar is hidden. + final bool? statusBarHidden; +} + +/// Result of a heal (治疗) action. +@MappableClass(caseStyle: CaseStyle.snakeCase) +final class HealResult with HealResultMappable { + /// Constructor. + const HealResult({required this.message, required this.currentHp, required this.maxHp, required this.cost}); + + /// Tolerant decode. + factory HealResult.fromMap(Map map) => HealResult( + message: map['message'] as String? ?? '', + currentHp: (map['current_hp'] as num?)?.toInt() ?? 0, + maxHp: (map['max_hp'] as num?)?.toInt() ?? 0, + cost: (map['cost'] as num?)?.toInt() ?? 0, + ); + + /// Server message, e.g. "xxx已治疗,花费 0 金币". + final String message; + + /// HP after healing. + final int currentHp; + + /// Maximum HP. + final int maxHp; + + /// Cost of the healing. + final int cost; +} diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 0f2b3197..211a7c53 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -137,6 +137,7 @@ "general": { "ok": "Ok", "cancel": "Cancel", + "networkError": "Network error, please retry", "failedToLoad": "Failed to load", "retry": "Retry", "noPermission": "No permission to view this content", @@ -1739,6 +1740,182 @@ "searchFailed": "Search for “${query}” failed.", "allResults": "All categories (searching)" }, + "pokemon": { + "title": "Pokemon Center", + "refresh": "Refresh", + "openBrowser": "Open in browser", + "loginRequired": "Session expired, please log in again.", + "failedToLoad": "Failed to load", + "myPokemon": "My Pokemon", + "pokemonCenter": "Pokemon Center", + "inventory": "Inventory", + "shop": "Shop", + "money": "Coins", + "empty": "Nothing here", + "first": "First", + "level": "Lv.$level", + "state": "State", + "stateNormal": "Normal", + "stateCritical": "Critical", + "stateSick": "Sick", + "stateHungry": "Hungry", + "stateTired": "Tired", + "stateExcited": "Excited", + "stateHurt": "Hurt", + "stateHappy": "Happy", + "statePanicked": "Panicked", + "stateVain": "Vain", + "stateAngry": "Angry", + "stateWeak": "Weak", + "evolutionPath": "Evolution path", + "evolutionForward": "Evolves into", + "evolutionBackward": "Evolves from", + "evolutionNone": "No evolution path", + "battleEnded": "The battle is over, go back to the map", + "hp": "HP", + "setFirst": "Set as first", + "storage": "Storage", + "toStorage": "Move to storage", + "putInBag": "Move to bag", + "rename": "Rename", + "release": "Release", + "releaseConfirm": "Release \"$name\"? This cannot be undone.", + "heal": "Heal", + "healAndFlee": "Flee & heal", + "use": "Use", + "useOn": "Use on which pokemon?", + "buy": "Buy", + "quantity": "Quantity", + "price": "Price", + "actionSuccess": "Done", + "actionFailed": "Action failed", + "stats": "Stats", + "skills": "Skills", + "skillPower": "Power", + "shiny": "Shiny", + "quality": "Quality", + "affection": "Affection", + "attack": "Attack", + "defense": "Defense", + "spAttack": "Sp. Atk", + "spDefense": "Sp. Def", + "speed": "Speed", + "noSkills": "No skills yet", + "skillRequiredLevel": "Needs Lv.$level", + "learnSkill": "Learn a skill", + "availableSkills": "Available", + "unlockedSkills": "Level too low", + "noLearnableSkills": "No skills can be learned", + "forgetSkill": "Forget", + "forgetConfirm": "Make \"$name\" forget the skill \"$skill\"? Its PP must be full.", + "skillLearned": "Skill learned", + "skillForgotten": "Skill forgotten", + "equipment": "Equipment", + "equipmentSlots": "Equipment slots", + "equipSlot": "Slot $index", + "unequip": "Unequip", + "ownedEquipment": "My equipment", + "equipmentShop": "Equipment shop", + "noEquipment": "No equipment", + "equipped": "Equipped", + "buyPetConfirm": "Buy \"$name\" for $price pet coins?", + "shopCategoryAll": "All", + "shopCategoryMed": "Medicine", + "shopCategoryBall": "Poke Balls", + "shopCategoryStone": "Stones", + "shopCategoryBoost": "Boosters", + "shopCategoryEquipment": "Equipment", + "shopCategoryPet": "Pokemon", + "searchItems": "Search items", + "viewGrid": "Grid view", + "viewList": "List view", + "exp": "EXP", + "expToNext": "$exp to next", + "searchPokemon": "Search pokemon", + "boxCount": "$count pokemon", + "boxFullHint": "Box is full: the bag and the storage share one capacity, so moving pokemon into the storage does not free room. Release one first.", + "carried": "Carried", + "carriedCount": "$count carried", + "skillSlotsFull": "Skill slots are full, forget one first", + "skillPpNotFull": "The skill's PP is not full, cannot forget", + "skillInBattle": "Cannot change skills during battle", + "skillLevelTooLow": "Level too low to learn this skill", + "skillNotLearnable": "This pokemon cannot learn this skill", + "skillAlreadyLearned": "Already learned", + "replaceSkillTitle": "Skill slots are full, choose one to forget", + "refreshBadge": "Refresh badge", + "badgeRefreshed": "Badge refreshed", + "statusBarPet": "Status bar pet", + "statusBarHidePet": "Hide the status bar pet", + "statusBarShowPet": "Show the status bar pet", + "statusBarPetHiddenDone": "Status bar pet hidden", + "statusBarPetShownDone": "Status bar pet shown", + "statusBarNeedLogin": "Sign in first", + "statusBarNotApplied": "The status bar action did not take effect, try again", + "formHashFailed": "The session check failed, try again", + "actionUnsupported": "This feature is not available: the server plugin build does not support it" + }, + "adventure": { + "title": "Adventure", + "refresh": "Refresh", + "failedToLoad": "Failed to load", + "loginRequired": "Session expired, please log in again.", + "empty": "No maps", + "level": "Lv.$level", + "levelRange": "Lv.$min ~ Lv.$max", + "mapCount": "$count locations", + "backToWorld": "Back to world map", + "challengeAdvice": "Advice", + "basicInfo": "Info", + "possiblePokemon": "Might appear", + "bossChallenge": "Boss challenge", + "terrainType": "Terrain", + "mapMode": "Mode", + "modeWild": "Wild", + "modeBoss": "Boss challenge", + "modeHybrid": "Hybrid", + "startAdventure": "Start adventure", + "back": "Back", + "adviceGood": "Good fit (your best Lv.$level)", + "adviceRisky": "Can challenge (your best Lv.$level)", + "adviceDanger": "Dangerous (your best Lv.$level)", + "adviceLowReward": "Level too high, experience gain is low", + "adviceUnknown": "Get a pokemon first", + "classicMode": "Classic list", + "regionMode": "By region" + }, + "battle": { + "title": "Battle", + "turn": "Turn $turn", + "failedToLoad": "Failed to load", + "loginRequired": "Session expired, please log in again.", + "actionFailed": "Action failed", + "noBalls": "No Poke Balls", + "noItems": "No usable items", + "items": "Items", + "capture": "Capture", + "switchPokemon": "Switch", + "flee": "Flee", + "normalAttack": "Attack", + "again": "Again", + "noSwitchable": "No pokemon to switch", + "confirmFlee": "Flee from this battle?", + "useOnSkill": "Use on which skill?", + "boss": "Boss", + "victory": "Victory!", + "defeat": "You lost...", + "fled": "Got away!", + "captured": "Caught!", + "rewards": "+$exp EXP, +$money coins", + "levelUp": "Leveled up to Lv.$level", + "ok": "OK", + "resynced": "Battle state refreshed", + "endedWhileAway": "The battle ended while you were away", + "fleeFailed": "Could not get away!", + "partyHealed": "Healed $count pokemon", + "goToCenter": "Go to the pet center", + "backToMap": "Back to the map" + }, "myTitlesPage": { "title": "My titles", "current": "ON", diff --git a/lib/i18n/zh-CN.i18n.json b/lib/i18n/zh-CN.i18n.json index 991d1e51..64fc1243 100644 --- a/lib/i18n/zh-CN.i18n.json +++ b/lib/i18n/zh-CN.i18n.json @@ -137,6 +137,7 @@ "general": { "ok": "确定", "cancel": "取消", + "networkError": "网络错误,请重试", "failedToLoad": "加载失败", "retry": "重试", "noPermission": "没有权限查看该内容", @@ -1739,6 +1740,182 @@ "searchFailed": "搜索“${query}”失败。", "allResults": "全部分类(搜索中)" }, + "pokemon": { + "title": "宠物中心", + "refresh": "刷新", + "openBrowser": "在浏览器打开", + "loginRequired": "登录已失效,请重新登录。", + "failedToLoad": "加载失败", + "myPokemon": "我的宠物", + "pokemonCenter": "宠物中心", + "inventory": "背包", + "shop": "商店", + "money": "宠物币", + "empty": "暂无数据", + "first": "首宠", + "level": "Lv.$level", + "state": "状态", + "stateNormal": "正常", + "stateCritical": "濒危", + "stateSick": "生病", + "stateHungry": "饥饿", + "stateTired": "疲惫", + "stateExcited": "兴奋", + "stateHurt": "受伤", + "stateHappy": "开心", + "statePanicked": "惊慌", + "stateVain": "自恋", + "stateAngry": "愤怒", + "stateWeak": "虚弱", + "evolutionPath": "进化路径", + "evolutionForward": "可以进化", + "evolutionBackward": "由谁进化", + "evolutionNone": "没有进化路线", + "battleEnded": "战斗已结束,请返回地图重新开始", + "hp": "HP", + "setFirst": "设为首宠", + "storage": "仓库", + "toStorage": "放入仓库", + "putInBag": "放进背包", + "rename": "改名", + "release": "放生", + "releaseConfirm": "确定要放生“$name”吗?此操作不可撤销。", + "heal": "治疗", + "healAndFlee": "脱战治疗", + "use": "使用", + "useOn": "对哪只宠物使用?", + "buy": "购买", + "quantity": "数量", + "price": "价格", + "actionSuccess": "操作成功", + "actionFailed": "操作失败", + "stats": "能力值", + "skills": "技能", + "skillPower": "威力", + "shiny": "闪光", + "quality": "品质", + "affection": "亲密度", + "attack": "攻击", + "defense": "防御", + "spAttack": "特攻", + "spDefense": "特防", + "speed": "速度", + "noSkills": "还没有技能", + "skillRequiredLevel": "需 Lv.$level", + "learnSkill": "学习技能", + "availableSkills": "可学习", + "unlockedSkills": "等级不足", + "noLearnableSkills": "没有可学习的技能", + "forgetSkill": "遗忘", + "forgetConfirm": "要让「$name」遗忘技能「$skill」吗?需要该技能 PP 已满。", + "skillLearned": "已学会技能", + "skillForgotten": "已遗忘技能", + "equipment": "装备", + "equipmentSlots": "装备栏", + "equipSlot": "装备槽 $index", + "unequip": "卸下", + "ownedEquipment": "我的装备", + "equipmentShop": "装备商店", + "noEquipment": "没有装备", + "equipped": "已装备", + "buyPetConfirm": "确定花 $price 宠物币购买「$name」吗?", + "shopCategoryAll": "全部", + "shopCategoryMed": "回复药", + "shopCategoryBall": "精灵球", + "shopCategoryStone": "进化石", + "shopCategoryBoost": "强化道具", + "shopCategoryEquipment": "装备", + "shopCategoryPet": "宠物", + "searchItems": "搜索道具", + "viewGrid": "网格视图", + "viewList": "列表视图", + "exp": "经验", + "expToNext": "距下级 $exp", + "searchPokemon": "搜索宠物", + "boxCount": "宠物箱:$count 只", + "boxFullHint": "宠物箱已满:背包与仓库共用同一容量,把宠物放进仓库不会腾出位置,请先放生。", + "carried": "随身", + "carriedCount": "随身:$count 只", + "skillSlotsFull": "技能槽已满,请先遗忘一个技能", + "skillPpNotFull": "该技能 PP 未满,无法遗忘", + "skillInBattle": "战斗中无法操作技能", + "skillLevelTooLow": "等级不足,无法学习该技能", + "skillNotLearnable": "该宠物无法学习此技能", + "skillAlreadyLearned": "已经学会该技能", + "replaceSkillTitle": "技能槽已满,选择要遗忘的技能", + "refreshBadge": "刷新勋章", + "badgeRefreshed": "勋章已刷新", + "statusBarPet": "状态栏宠物", + "statusBarHidePet": "隐藏状态栏宠物", + "statusBarShowPet": "显示状态栏宠物", + "statusBarPetHiddenDone": "已隐藏状态栏宠物", + "statusBarPetShownDone": "已显示状态栏宠物", + "statusBarNeedLogin": "请先登录后再操作", + "statusBarNotApplied": "状态栏操作未生效,请重试", + "formHashFailed": "登录校验已失效,请重试", + "actionUnsupported": "该功能当前不可用:服务器插件版本不支持" + }, + "adventure": { + "title": "冒险", + "refresh": "刷新", + "failedToLoad": "加载失败", + "loginRequired": "登录已失效,请重新登录。", + "empty": "暂无地图", + "level": "Lv.$level", + "levelRange": "Lv.$min ~ Lv.$max", + "mapCount": "共 $count 个地点", + "backToWorld": "返回世界地图", + "challengeAdvice": "挑战建议", + "basicInfo": "基本信息", + "possiblePokemon": "可能出现", + "bossChallenge": "Boss 挑战", + "terrainType": "地形类型", + "mapMode": "地图模式", + "modeWild": "野生模式", + "modeBoss": "Boss 挑战", + "modeHybrid": "混合模式", + "startAdventure": "开始冒险", + "back": "返回", + "adviceGood": "适合挑战(你的宠物最高 Lv.$level)", + "adviceRisky": "可以挑战(你的宠物最高 Lv.$level)", + "adviceDanger": "危险(你的宠物最高 Lv.$level)", + "adviceLowReward": "等级过高,经验收益较低", + "adviceUnknown": "还没有宝可梦,先去获得一只吧", + "classicMode": "经典模式", + "regionMode": "地区模式" + }, + "battle": { + "title": "战斗", + "turn": "第 $turn 回合", + "failedToLoad": "加载失败", + "loginRequired": "登录已失效,请重新登录。", + "actionFailed": "操作失败", + "noBalls": "没有精灵球", + "noItems": "没有可用的道具", + "items": "道具", + "capture": "捕捉", + "switchPokemon": "换宠", + "flee": "逃跑", + "normalAttack": "普通攻击", + "again": "再战", + "noSwitchable": "没有可换的宝可梦", + "confirmFlee": "确定要逃跑吗?", + "useOnSkill": "对哪个技能使用?", + "boss": "Boss", + "victory": "胜利!", + "defeat": "战败了...", + "fled": "成功逃脱了!", + "captured": "捕捉成功!", + "rewards": "获得经验 +$exp,金币 +$money", + "levelUp": "升级到 Lv.$level", + "ok": "确定", + "resynced": "已重新同步战况", + "endedWhileAway": "战斗已结束", + "fleeFailed": "逃跑失败!", + "partyHealed": "已治疗 $count 只宝可梦", + "goToCenter": "前往宠物中心", + "backToMap": "返回地图" + }, "myTitlesPage": { "title": "我的称号", "current": "使用中", diff --git a/lib/i18n/zh-TW.i18n.json b/lib/i18n/zh-TW.i18n.json index fbe232e2..73b13b00 100644 --- a/lib/i18n/zh-TW.i18n.json +++ b/lib/i18n/zh-TW.i18n.json @@ -137,6 +137,7 @@ "general": { "ok": "確定", "cancel": "取消", + "networkError": "網路錯誤,請重試", "failedToLoad": "載入失敗", "retry": "重試", "noPermission": "沒有權限查看該內容", @@ -1739,6 +1740,182 @@ "searchFailed": "搜尋「${query}」失敗。", "allResults": "全部分類(搜尋中)" }, + "pokemon": { + "title": "寵物中心", + "refresh": "重新整理", + "openBrowser": "在瀏覽器開啟", + "loginRequired": "登入已失效,請重新登入。", + "failedToLoad": "載入失敗", + "myPokemon": "我的寵物", + "pokemonCenter": "寵物中心", + "inventory": "背包", + "shop": "商店", + "money": "寵物幣", + "empty": "暫無資料", + "first": "首寵", + "level": "Lv.$level", + "state": "狀態", + "stateNormal": "正常", + "stateCritical": "瀕危", + "stateSick": "生病", + "stateHungry": "飢餓", + "stateTired": "疲憊", + "stateExcited": "興奮", + "stateHurt": "受傷", + "stateHappy": "開心", + "statePanicked": "驚慌", + "stateVain": "自戀", + "stateAngry": "憤怒", + "stateWeak": "虛弱", + "evolutionPath": "進化路徑", + "evolutionForward": "可以進化", + "evolutionBackward": "由誰進化", + "evolutionNone": "沒有進化路線", + "battleEnded": "戰鬥已結束,請返回地圖重新開始", + "hp": "HP", + "setFirst": "設為首寵", + "storage": "倉庫", + "toStorage": "放入倉庫", + "putInBag": "放進背包", + "rename": "改名", + "release": "放生", + "releaseConfirm": "確定要放生「$name」嗎?此操作無法復原。", + "heal": "治療", + "healAndFlee": "脫戰治療", + "use": "使用", + "useOn": "要對哪隻寵物使用?", + "buy": "購買", + "quantity": "數量", + "price": "價格", + "actionSuccess": "操作成功", + "actionFailed": "操作失敗", + "stats": "能力值", + "skills": "技能", + "skillPower": "威力", + "shiny": "閃光", + "quality": "品質", + "affection": "親密度", + "attack": "攻擊", + "defense": "防禦", + "spAttack": "特攻", + "spDefense": "特防", + "speed": "速度", + "noSkills": "還沒有技能", + "skillRequiredLevel": "需 Lv.$level", + "learnSkill": "學習技能", + "availableSkills": "可學習", + "unlockedSkills": "等級不足", + "noLearnableSkills": "沒有可學習的技能", + "forgetSkill": "遺忘", + "forgetConfirm": "要讓「$name」遺忘技能「$skill」嗎?需要該技能 PP 已滿。", + "skillLearned": "已學會技能", + "skillForgotten": "已遺忘技能", + "equipment": "裝備", + "equipmentSlots": "裝備欄", + "equipSlot": "裝備槽 $index", + "unequip": "卸下", + "ownedEquipment": "我的裝備", + "equipmentShop": "裝備商店", + "noEquipment": "沒有裝備", + "equipped": "已裝備", + "buyPetConfirm": "確定花 $price 寵物幣購買「$name」嗎?", + "shopCategoryAll": "全部", + "shopCategoryMed": "回復藥", + "shopCategoryBall": "精靈球", + "shopCategoryStone": "進化石", + "shopCategoryBoost": "強化道具", + "shopCategoryEquipment": "裝備", + "shopCategoryPet": "寵物", + "searchItems": "搜尋道具", + "viewGrid": "網格檢視", + "viewList": "列表檢視", + "exp": "經驗", + "expToNext": "距下級 $exp", + "searchPokemon": "搜尋寵物", + "boxCount": "寵物箱:$count 隻", + "boxFullHint": "寵物箱已滿:背包與倉庫共用同一容量,把寵物放進倉庫不會騰出位置,請先放生。", + "carried": "隨身", + "carriedCount": "隨身:$count 隻", + "skillSlotsFull": "技能槽已滿,請先遺忘一個技能", + "skillPpNotFull": "該技能 PP 未滿,無法遺忘", + "skillInBattle": "戰鬥中無法操作技能", + "skillLevelTooLow": "等級不足,無法學習該技能", + "skillNotLearnable": "該寵物無法學習此技能", + "skillAlreadyLearned": "已經學會該技能", + "replaceSkillTitle": "技能槽已滿,選擇要遺忘的技能", + "refreshBadge": "重新整理徽章", + "badgeRefreshed": "徽章已重新整理", + "statusBarPet": "狀態欄寵物", + "statusBarHidePet": "隱藏狀態欄寵物", + "statusBarShowPet": "顯示狀態欄寵物", + "statusBarPetHiddenDone": "已隱藏狀態欄寵物", + "statusBarPetShownDone": "已顯示狀態欄寵物", + "statusBarNeedLogin": "請先登入後再操作", + "statusBarNotApplied": "狀態欄操作未生效,請重試", + "formHashFailed": "登入驗證已失效,請重試", + "actionUnsupported": "該功能目前無法使用:伺服器外掛版本不支援" + }, + "adventure": { + "title": "冒險", + "refresh": "重新整理", + "failedToLoad": "載入失敗", + "loginRequired": "登入已失效,請重新登入。", + "empty": "暫無地圖", + "level": "Lv.$level", + "levelRange": "Lv.$min ~ Lv.$max", + "mapCount": "共 $count 個地點", + "backToWorld": "返回世界地圖", + "challengeAdvice": "挑戰建議", + "basicInfo": "基本資訊", + "possiblePokemon": "可能出現", + "bossChallenge": "Boss 挑戰", + "terrainType": "地形類型", + "mapMode": "地圖模式", + "modeWild": "野生模式", + "modeBoss": "Boss 挑戰", + "modeHybrid": "混合模式", + "startAdventure": "開始冒險", + "back": "返回", + "adviceGood": "適合挑戰(你的寵物最高 Lv.$level)", + "adviceRisky": "可以挑戰(你的寵物最高 Lv.$level)", + "adviceDanger": "危險(你的寵物最高 Lv.$level)", + "adviceLowReward": "等級過高,經驗收益較低", + "adviceUnknown": "還沒有寶可夢,先去獲得一隻吧", + "classicMode": "經典模式", + "regionMode": "地區模式" + }, + "battle": { + "title": "戰鬥", + "turn": "第 $turn 回合", + "failedToLoad": "載入失敗", + "loginRequired": "登入已失效,請重新登入。", + "actionFailed": "操作失敗", + "noBalls": "沒有精靈球", + "noItems": "沒有可用的道具", + "items": "道具", + "capture": "捕捉", + "switchPokemon": "換寵", + "flee": "逃跑", + "normalAttack": "普通攻擊", + "again": "再戰", + "noSwitchable": "沒有可換的寶可夢", + "confirmFlee": "確定要逃跑嗎?", + "useOnSkill": "要對哪個技能使用?", + "boss": "Boss", + "victory": "勝利!", + "defeat": "戰敗了...", + "fled": "成功逃脫了!", + "captured": "捕捉成功!", + "rewards": "獲得經驗 +$exp,金幣 +$money", + "levelUp": "升級到 Lv.$level", + "ok": "確定", + "resynced": "已重新同步戰況", + "endedWhileAway": "戰鬥已結束", + "fleeFailed": "逃跑失敗!", + "partyHealed": "已治療 $count 隻寶可夢", + "goToCenter": "前往寵物中心", + "backToMap": "返回地圖" + }, "myTitlesPage": { "title": "我的頭銜", "current": "使用中", From f6627f1674c2af734219aca6f7d0463ee1e3527b Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 12:58:22 +0800 Subject: [PATCH 02/37] feat(net): support the plugin json api and make its requests diagnosable Adds the JSON post the plugin's write calls need (dart and the android side), logs the method and url of every write and of every answer that is not ok, keeps a 404 out of the network error banner, and relaxes the android http timeouts the slow heal and sprite downloads hit. --- .../kzs/th000/tsdm_client/HttpClient.kt | 29 ++++++++++- .../kzs/th000/tsdm_client/MainActivity.kt | 19 ++++++++ .../net_client_provider.dart | 48 +++++++++++++++++-- .../net_client_provider_android.dart | 28 +++++++++++ 4 files changed, 120 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/kotlin/kzs/th000/tsdm_client/HttpClient.kt b/android/app/src/main/kotlin/kzs/th000/tsdm_client/HttpClient.kt index 6df239ae..f8500de5 100644 --- a/android/app/src/main/kotlin/kzs/th000/tsdm_client/HttpClient.kt +++ b/android/app/src/main/kotlin/kzs/th000/tsdm_client/HttpClient.kt @@ -4,17 +4,27 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.FormBody import okhttp3.Headers +import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import okio.BufferedSink import java.net.ProxySelector +import java.util.concurrent.TimeUnit object HttpClient { + // The forum and its CDN answer slowly under load (the 10s OkHttp defaults timed out the pokemon heal and the + // sprite downloads on slower devices), so relax the timeouts. Write stays generous for uploads. private val client by lazy { - OkHttpClient.Builder().proxySelector(ProxySelector.getDefault()).build() + OkHttpClient.Builder() + .proxySelector(ProxySelector.getDefault()) + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .build() } // Share connections and dispatchers, but never replay a non-idempotent transaction. @@ -103,6 +113,23 @@ object HttpClient { } } } + + suspend fun postJson(url: String, headers: HashMap, body: String): Response { + val requestBody = body.toRequestBody("application/json; charset=utf-8".toMediaType()) + val request = Request.Builder() + .url(url) + .headers(Headers.headersOf(*headers.toList().flatMap { listOf(it.first, it.second) }.toTypedArray())) + .post(requestBody) + .build() + + return withContext(Dispatchers.IO) { + try { + client.newCall(request).execute() + } catch (e: Exception) { + throw e + } + } + } } fun buildResponse( diff --git a/android/app/src/main/kotlin/kzs/th000/tsdm_client/MainActivity.kt b/android/app/src/main/kotlin/kzs/th000/tsdm_client/MainActivity.kt index 06675287..0c476c0e 100644 --- a/android/app/src/main/kotlin/kzs/th000/tsdm_client/MainActivity.kt +++ b/android/app/src/main/kotlin/kzs/th000/tsdm_client/MainActivity.kt @@ -28,6 +28,7 @@ class MainActivity: FlutterActivity() { const val HTTP_GET = "get" const val HTTP_POST_FORM = "postForm" const val HTTP_POST_MULTIPART = "postMultipart" + const val HTTP_POST_JSON = "postJson" /** Window size events sent to Dart, see `lib/utils/window_events.dart` (GitHub #28). */ const val WINDOW_CHANNEL = "kzs.th000.tsdm_client/windowChannel" @@ -250,6 +251,24 @@ class MainActivity: FlutterActivity() { } } + HTTP_POST_JSON -> { + val url = call.argument("url")!! + val headers = call.argument>("headers")!! + val body = call.argument("body")!! + CoroutineScope(Dispatchers.IO + SupervisorJob()).launch { + try { + val resp = HttpClient.postJson(url, headers, body) + val statusCode = resp.code + val headers = HashMap(resp.headers.toMultimap()) + val body = resp.body.bytes() + val isRedirect = resp.isRedirect + result.success(buildResponse(statusCode, headers, body, isRedirect)) + } catch (e: Exception) { + Log.e("KT_HTTP_ERROR", "failed to post json: ${e.message ?: "unknown error"}") + result.error("KT_HTTP_ERROR", "failed to perform http POST json", e.message ?: "unknown error") + } + } + } else -> { result.notImplemented() } diff --git a/lib/shared/providers/net_client_provider/net_client_provider.dart b/lib/shared/providers/net_client_provider/net_client_provider.dart index 25da5f45..a57aba13 100644 --- a/lib/shared/providers/net_client_provider/net_client_provider.dart +++ b/lib/shared/providers/net_client_provider/net_client_provider.dart @@ -36,7 +36,7 @@ AppException mapException(Object error, StackTrace st) { } if (error case DioException(:final response)) { return HttpHandshakeFailedException( - error.message ?? '', + error.message ?? error.error?.toString() ?? error.type.name, statusCode: response?.statusCode, headers: response?.headers, ); @@ -213,6 +213,38 @@ final class NetClientProvider with LoggerMixin { bool singleAttempt = false, }) => _dio.postWithForm(path, data: data, queryParameters: queryParameters, singleAttempt: singleAttempt); + /// Post [data] as JSON (`Content-Type: application/json`) to [path]. + /// + /// The response body is returned as a plain string and every status code is accepted so the caller can read the + /// plugin's JSON envelope (including its error body) itself. [headers] are added to this request's headers. + AsyncEither> postJson( + String path, { + Object? data, + Map? queryParameters, + Map? headers, + }) => AsyncEither.tryCatch( + () async { + final resp = await _dio.post( + path, + data: data, + queryParameters: queryParameters, + options: Options( + headers: {HttpHeaders.contentTypeHeader: Headers.jsonContentType, ...?headers}, + responseType: ResponseType.plain, + validateStatus: (_) => true, + ), + ); + final status = resp.statusCode ?? 0; + if (status < HttpStatus.ok || status >= HttpStatus.multipleChoices) { + // Every status is accepted above so the caller can read the plugin's own envelope; log the bad ones anyway, or a + // call the server keeps rejecting leaves no trace in an exported log. + warning('${resp.requestOptions.method} ${resp.requestOptions.uri} answered $status'); + } + return resp; + }, + mapException, + ); + /// Post a form [data] to url [path] in `Content-Type` multipart/form-data. /// /// Automatically set `Content-Type` to `multipart/form-data`. @@ -360,7 +392,15 @@ class _ErrorHandler extends Interceptor with LoggerMixin { @override void onError(DioException err, ErrorInterceptorHandler handler) { - error('${err.requestOptions.uri} ${err.type}: error: ${err.error}, status code: ${err.response?.statusCode}'); + final status = err.response?.statusCode; + // A missing resource (an avatar a user never uploaded, for example) is not a network problem, so keep it out of the + // error log and out of the network error banner. + if (err.type == DioExceptionType.badResponse && status == 404) { + debug('${err.requestOptions.uri} ${err.type}: status code: $status'); + handler.next(err); + return; + } + error('${err.requestOptions.uri} ${err.type}: error: ${err.error}, status code: $status'); getIt.get().save(err.message); if (err.type == DioExceptionType.badResponse) { @@ -472,7 +512,9 @@ final class _GzipEncodingChecker extends Interceptor with LoggerMixin { void onRequest(RequestOptions options, RequestInterceptorHandler handler) { // Likely to have redirect on post methods. if (options.method != 'GET' || options.uri.queryParameters['goto'] == 'findpost') { - info('removing gzip encoding in request'); + // The method and the url belong in the line: without them a burst of these lines cannot be traced back to a caller + // from an exported log, where a repeating write request otherwise leaves no trace at all. + info('removing gzip encoding in request: ${options.method} ${options.uri}'); options.headers[HttpHeaders.acceptEncodingHeader] = 'deflate, br'; } diff --git a/lib/shared/providers/net_client_provider/net_client_provider_android.dart b/lib/shared/providers/net_client_provider/net_client_provider_android.dart index b92d44a5..3465f951 100644 --- a/lib/shared/providers/net_client_provider/net_client_provider_android.dart +++ b/lib/shared/providers/net_client_provider/net_client_provider_android.dart @@ -17,6 +17,7 @@ abstract class _AndroidHttpMethodChannel { static const _methodGet = 'get'; static const _methodPost = 'postForm'; static const _methodPostMultipart = 'postMultipart'; + static const _methodPostJson = 'postJson'; /// Make a GET request. static Future<_KotlinHttpResponse?> _get({required String url, required Map headers}) async { @@ -77,6 +78,28 @@ abstract class _AndroidHttpMethodChannel { return null; } } + + /// Post a raw JSON body. + static Future<_KotlinHttpResponse?> _postJson({ + required String url, + required Map headers, + required String body, + }) async { + try { + final resp = await _httpChannel.invokeMethod>(_methodPostJson, { + 'url': url, + 'headers': headers, + 'body': body, + }); + if (resp == null) { + return null; + } + return _KotlinHttpResponse._fromRawResp(resp); + } on PlatformException catch (e, st) { + talker.handle(e, st, '[KtHttp] POST json failed on $url'); + return null; + } + } } /// The http response. @@ -171,6 +194,11 @@ final class KotlinHttpClient { headers: headers ?? {}, body: body as Map, ), + 'application/json' => await _AndroidHttpMethodChannel._postJson( + url: url.toString(), + headers: headers ?? {}, + body: body as String, + ), final v => throw UnsupportedError('unsupported content type: $v'), }; if (rawResp == null) { From 6dd1687b993ede976e7fbed9e3e97f64d4cd2085 Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 12:58:23 +0800 Subject: [PATCH 03/37] feat(pokemon): add the repository, its caches and per-call deadlines One place attaches the session formhash, retries once when the server calls it stale and runs every call under a deadline (20s, 60s for the map list), so a request the platform client parked is reported as a network failure instead of holding a page in its busy state. --- .../pokemon/repository/adventure_cache.dart | 150 ++++++ .../repository/pokemon_repository.dart | 509 ++++++++++++++++++ .../pokemon/repository/skill_order_store.dart | 41 ++ lib/features/pokemon/utils/pokemon_image.dart | 35 ++ 4 files changed, 735 insertions(+) create mode 100644 lib/features/pokemon/repository/adventure_cache.dart create mode 100644 lib/features/pokemon/repository/pokemon_repository.dart create mode 100644 lib/features/pokemon/repository/skill_order_store.dart create mode 100644 lib/features/pokemon/utils/pokemon_image.dart diff --git a/lib/features/pokemon/repository/adventure_cache.dart b/lib/features/pokemon/repository/adventure_cache.dart new file mode 100644 index 00000000..3718bd11 --- /dev/null +++ b/lib/features/pokemon/repository/adventure_cache.dart @@ -0,0 +1,150 @@ +import 'package:fpdart/fpdart.dart'; +import 'package:tsdm_client/exceptions/exceptions.dart'; +import 'package:tsdm_client/features/pokemon/models/models.dart'; +import 'package:tsdm_client/features/pokemon/repository/pokemon_repository.dart'; +import 'package:tsdm_client/instance.dart'; +import 'package:tsdm_client/shared/providers/cookie_provider/cookie_provider.dart'; +import 'package:tsdm_client/shared/providers/storage_provider/storage_provider.dart'; + +/// Cross-page cache of the adventure data. +/// +/// The maps endpoint is slow (the plugin runs one sub-query for every map), so the map list is kept across page +/// entries: +/// re-entering the adventure page shows the cached list at once and refreshes it in the background instead of blocking +/// on a full-page spinner every time. +final class AdventureCache { + /// Constructor. + AdventureCache({PokemonRepository? repository}) : _repository = repository ?? PokemonRepository(); + + final PokemonRepository _repository; + + List? _maps; + + List? _pokemons; + + /// Account the remembered data belongs to. + /// + /// This cache is a singleton, so without the check a player who switches account would see the previous account's + /// maps, party and finished battles. + int? _accountUid; + + /// When the server last answered that there is no battle at all. + /// + /// The adventure page is entered again and again (returning from a fight, hopping between tabs) and every entry asks + /// the server whether a battle is running. A battle cannot appear between two entries a moment apart, so the answer + /// is reused for [_battleCheckWindow]. + DateTime? _noBattleAt; + + /// How long a "no battle" answer is trusted. + static const _battleCheckWindow = Duration(seconds: 10); + + /// Whether the "is a battle running?" check can be skipped: the server answered it moments ago. + bool get battleCheckFresh => _noBattleAt != null && DateTime.now().difference(_noBattleAt!) < _battleCheckWindow; + + /// Remember that the server answered "no battle" just now. + void markNoBattle() => _noBattleAt = DateTime.now(); + + /// Forget that answer: a battle may well be running now. + void invalidateBattleCheck() => _noBattleAt = null; + + /// Forget what was remembered for another account. + void _forgetOtherAccount() { + final uid = getIt.get().userLoginInfo.uid; + if (_accountUid == uid) return; + _accountUid = uid; + _maps = null; + _pokemons = null; + _finishedBattles.clear(); + _noBattleAt = null; + } + + /// The last fetched map list, or null before the first successful load. + List? get maps { + _forgetOtherAccount(); + return _maps; + } + + /// Whether a map list has been fetched at least once. + bool get hasData { + _forgetOtherAccount(); + return _maps != null; + } + + /// The level of the pokemon a fight would start with, or null before the list is loaded / when it is empty. + /// + /// That is the pokemon in front (`site` 1). The website's own map advice reads the first pokemon of the list + /// (`get_recommendation` in `rust/game/src/pages/adventure.rs`), which is the same one — and it is the pet a fight + /// actually starts with, so the advice is about the right pet. + int? get activePokemonLevel { + _forgetOtherAccount(); + final list = _pokemons; + if (list == null || list.isEmpty) return null; + return list.firstWhere((p) => p.isActive, orElse: () => list.first).level; + } + + /// Refresh the remembered pokemon list (best effort), used for the challenge advice. + Future refreshPokemons() async { + _forgetOtherAccount(); + final result = await _repository.getMyPokemon().run(); + result.fold((_) => null, (list) => _pokemons = list); + } + + /// Fetch the maps and remember them; the previous list is kept when the fetch fails. + Future>> refreshMaps() async { + _forgetOtherAccount(); + final result = await _repository.getMaps().run(); + result.fold((_) => null, (maps) => _maps = maps); + return result; + } + + /// Resume an active battle, if any (the server answers 404 when there is none). + AsyncEither recoverBattle() => _repository.recoverBattle(); + + /// Start a battle on [mapId], optionally against boss [bossTypeId]. + AsyncEither startBattle(int mapId, {int? bossTypeId}) => + _repository.startBattle(mapId, bossTypeId: bossTypeId); + + /// Flee battle [battleId]; best effort, the plugin rolls for the escape to succeed. + AsyncEither flee(String battleId) => _repository.flee(battleId); + + /// Heal every carried pokemon that needs it; returns how many were healed. + Future healParty() => _repository.healParty(); + + /// Battles the player already finished in this app run. + /// + /// The plugin does not always clear a battle when the player loses it (it keeps it while a usable backup pokemon + /// exists, see `BattleCubit.finishDefeat`), so the adventure page must not resume these: it ends them instead. + final Set _finishedBattles = {}; + + /// Key identifying [scene]'s battle in [_finishedBattles]. + /// + /// `battleId` is empty in the end-of-battle responses, so the map, the opponent and the player's pokemon are used. + static String battleKey(BattleScene scene) => + '${scene.mapId}-${scene.wildPokemon.id}-${scene.myPokemon.instanceId}'; + + /// Remember that [scene]'s battle is over. + void markBattleFinished(BattleScene scene) { + _forgetOtherAccount(); + _finishedBattles.add(battleKey(scene)); + } + + /// Whether [scene]'s battle is one the player already finished. + bool isBattleFinished(BattleScene scene) { + _forgetOtherAccount(); + return _finishedBattles.contains(battleKey(scene)); + } + + /// Forget every finished battle; called once the server reports no active battle at all. + void clearFinishedBattles() => _finishedBattles.clear(); + + /// Storage key remembering whether the player prefers the flat "classic" map list. + static const keyClassicMode = 'pokemon.adventureClassic'; + + /// Whether the player last chose the flat classic map list (instead of the world map / region view). + Future isClassicMode() async => + (await getIt.get().getString(keyClassicMode)) == 'true'; + + /// Remember the chosen map list mode. + Future setClassicMode({required bool classic}) => + getIt.get().saveString(keyClassicMode, classic ? 'true' : 'false'); +} diff --git a/lib/features/pokemon/repository/pokemon_repository.dart b/lib/features/pokemon/repository/pokemon_repository.dart new file mode 100644 index 00000000..65f1c60a --- /dev/null +++ b/lib/features/pokemon/repository/pokemon_repository.dart @@ -0,0 +1,509 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:tsdm_client/constants/url.dart'; +import 'package:tsdm_client/exceptions/exceptions.dart'; +import 'package:tsdm_client/features/pokemon/models/models.dart'; +import 'package:tsdm_client/instance.dart'; +import 'package:tsdm_client/shared/providers/cookie_provider/cookie_provider.dart'; +import 'package:tsdm_client/shared/providers/net_client_provider/net_client_provider.dart'; +import 'package:tsdm_client/utils/logger.dart'; + +/// Repository of the pokemon (宠物中心) plugin JSON API. +/// +/// The plugin exposes a pure JSON API at `plugin.php?id=pokemon:pokemon&endpoint=X&action=Y`. Every response uses the +/// envelope `{"success": bool, "data": ..., "error": ..., "code": ...}` and is authenticated by the forum session +/// cookie, so requests go through the default [NetClientProvider] bound to the current account. +final class PokemonRepository with LoggerMixin { + /// Constructor. + /// + /// [requestDeadline] is how long one plugin call may take before it is reported as a network failure (see [_send]); + /// a test passes a short one to exercise a call that never answers. + PokemonRepository({Duration requestDeadline = _timeout}) : _requestDeadline = requestDeadline; + + /// Deadline of a plugin call that does not ask for another one. + final Duration _requestDeadline; + + /// Options that keep the raw string body, accept any status code and carry [headers]. + /// + /// The timeouts bound how long a request that the OS parked while the app was in the background may hold a page in + /// its loading state; the platform client alone would wait much longer. + static Options _options([Map headers = const {}]) => Options( + responseType: ResponseType.plain, + validateStatus: _anyStatus, + sendTimeout: _timeout, + receiveTimeout: _timeout, + headers: headers, + ); + + /// Longest a pokemon request may take before its page gives up on it. + /// + /// Set on the request options and applied again as a deadline in [_send]: the options only bind the platforms that + /// honour them, and a request the platform client parked must not hold a page in its busy state until that client + /// gives up on its own (the plugin answers a call in a second or two, apart from the map list). + static const _timeout = Duration(seconds: 20); + + /// Deadline of the map list: on the plugin side it runs a sub-query for every map, so it is the one slow endpoint. + static const _mapsDeadline = Duration(seconds: 60); + + /// Header the plugin's API expects for its cross-site request check. + /// + /// The plugin answers `formhash 校验失败,请刷新页面后重试` when a request does not carry the session's formhash, so it + /// is read from a page of the same session (see [_ensureFormHash]) and sent with every call. + static const _formHashHeader = 'X-Pm-Formhash'; + + /// Page the session's formhash is read from; the website's own front-end reads the same value. + static const _formHashPageUrl = '$baseUrl/plugin.php?id=pokemon:game'; + + /// Formhash of the current session, cached because it is bound to the cookie session. + static String? _formHash; + + /// A formhash read that is still in flight, so the requests of one page load share a single read of the page. + static Future? _formHashRead; + + /// Client this repository sends its requests through, and the account it was built for. + /// + /// One client is kept for the repository's life so its connection (and the cookie jar inside it) is reused: building + /// one per request pays a TCP + TLS handshake every time (measured ≈0.45s to the forum host), and a page load makes + /// several requests in a row. The client carries that account's cookies, so another account drops it. + NetClientProvider? _client; + int? _clientUid; + + NetClientProvider get _net { + final uid = getIt.get().userLoginInfo.uid; + if (_client != null && _clientUid == uid) { + return _client!; + } + _clientUid = uid; + return _client = getIt.get(); + } + + static bool _anyStatus(int? status) => true; + + /// Fetch the global config. + AsyncEither getGlobalConfig() => _getData('config&action=global_config', GlobalConfig.fromJson); + + /// Fetch the current user's pokemon list. + AsyncEither> getMyPokemon() => + _getData('pokemon&action=list', (data) => _listOf(data['pokemons'], Pokemon.fromMap)); + + /// The evolution chain of species [speciesId]: what it can become and what it evolved from, with each step's + /// condition. + /// + /// This is what the website's "进化路径" dialog reads (`evolution&action=evolution_path`); it is display only. + AsyncEither getEvolutionPath(int speciesId) => + _getData('evolution&action=evolution_path&pmid=$speciesId', EvolutionPath.fromMap); + + /// Fetch the full detail (with [PokemonStats]) of pokemon [id]. + AsyncEither getPokemonDetail(int id) => + _getData('pokemon&action=detail&pokemon_id=$id', Pokemon.fromMap); + + /// Fetch the current user's plugin profile (money etc.). + AsyncEither getProfile() => _getData('user&action=profile', PokemonUserProfile.fromMap); + + /// Fetch a page of the current user's inventory. + AsyncEither getInventory({int page = 1, int? type}) => + _getData('user&action=inventory&page=$page${type == null ? '' : '&type=$type'}', InventoryPage.fromMap); + + /// Fetch a page of the shop. + AsyncEither getShop({int page = 1, int? type}) => + _getData('shop&action=list&page=$page${type == null ? '' : '&type=$type'}', ShopPage.fromMap); + + /// Set pokemon [id] as the first (battling) pokemon. + AsyncVoidEither setFirst(int id) => _postAction('pokemon&action=set_first&pokemon_id=$id'); + + /// Move pokemon [id] to [site] (1=first, 2=bag, 3=storage). + AsyncVoidEither movePokemon(int id, int site) => _postAction('pokemon&action=move_pokemon&pokemon_id=$id&site=$site'); + + /// Rename pokemon [id] to [name]. + AsyncVoidEither rename(int id, String name) => _postAction('pokemon&action=rename', body: {'id': id, 'name': name}); + + /// Release (放生) pokemon [id]. + AsyncVoidEither release(int id) => _postAction('pokemon&action=release', body: {'id': id}); + + /// Fetch the skills pokemon [id] can still learn. + AsyncEither getLearnableSkills(int id) => + _getData('pokemon&action=learnable_skills&pokemon_id=$id', LearnableSkills.fromMap); + + /// Make pokemon [id] learn skill [skillId], optionally into [slotIndex]. + AsyncVoidEither learnSkill(int id, int skillId, {int? slotIndex}) => _postAction( + 'pokemon&action=learn_skill', + body: {'pokemon_id': id, 'skill_id': skillId, 'slot_index': ?slotIndex}, + ); + + /// Make pokemon [id] forget skill [skillId]. + AsyncVoidEither forgetSkill(int id, int skillId) => + _postAction('pokemon&action=forget_skill', body: {'pokemon_id': id, 'skill_id': skillId}); + + /// Fetch the equipment of pokemon [id]: its slots, owned items and the shop. + AsyncEither getEquipment(int id) => + _getData('pokemon&action=equipment&pokemon_id=$id', EquipmentResponse.fromMap); + + /// Equip owned item [myitemId] on pokemon [id], optionally into [slotIndex]. + AsyncVoidEither equipItem(int id, int myitemId, {int? slotIndex}) => _postAction( + 'pokemon&action=equip_item', + body: {'pokemon_id': id, 'myitem_id': myitemId, 'slot_index': ?slotIndex}, + ); + + /// Unequip the equipment in [slotIndex] (0-3) of pokemon [id]. + AsyncVoidEither unequipItem(int id, int slotIndex) => + _postAction('pokemon&action=unequip_item', body: {'pokemon_id': id, 'slot_index': slotIndex}); + + /// Heal pokemon [id] at the pokemon center. + AsyncEither heal(int id) => + _postData('user&action=heal&pokemon_id=$id', body: const {}, decode: HealResult.fromMap); + + /// Flee any active battle and heal pokemon [id]. + AsyncEither healAndFlee(int id) => + _postData('user&action=heal_and_flee&pokemon_id=$id', body: const {}, decode: HealResult.fromMap); + + /// The current user's party, or null when the request failed. + Future?> loadParty() async { + final result = await getMyPokemon().run(); + return result.fold((_) => null, (list) => list); + } + + /// Heal every carried pokemon that needs it; returns how many were healed. + /// + /// The heal is free and idempotent, so this is what the battle result and the adventure page call around a fight. + /// Working from the pokemon list instead of one scene means a pet that sat the fight out — or one the scene named + /// wrongly — is healed too; failures are skipped (best effort). + /// + /// The heals run together, so a party that spent PP during the fight costs one round trip instead of one per pokemon. + Future healParty() async { + final pokemons = await loadParty(); + if (pokemons == null) { + // The heal runs in the background after a fight, so a failed list read has to leave a trace somewhere. + warning('healParty: could not read the party, nothing healed'); + return 0; + } + final candidates = pokemons.where((pokemon) => pokemon.needsHealing).toList(); + if (candidates.isEmpty) return 0; + final stopwatch = Stopwatch()..start(); + final results = await Future.wait(candidates.map((pokemon) => heal(pokemon.id).run())); + final healed = results.where((result) => result.isRight()).length; + debug('healParty: ${candidates.length} heals in ${stopwatch.elapsedMilliseconds}ms -> $healed healed'); + return healed; + } + + /// Refresh the forum-side pokemon badge and the status bar pet; the raw `data` holds a message and the counts. + /// + /// The forum stores its own serialized copy of the party, so the badge (and the level shown under the avatar) stays + /// stale until this is called. The website's status bar switch is this same call: [hide] hides the pet instead of + /// writing the party back, and the answer then carries `hidden`. + AsyncEither> refreshBadge({bool? hide}) => + _postRaw('user&action=refresh_badge', {'hide': ?hide}); + + /// Read the badge and status bar pet state; the raw `data` says whether the pet is hidden. + /// + /// This is what the website's sidebar reads (`get_badge_status` in `rust/game/src/utils/api_client.rs`) before it + /// switches the pet. The profile carries the same flag, but only on plugin builds that send it, so this is the + /// reliable source; a build without the action simply answers an error and the profile value is kept. + AsyncEither> badgeStatus() => _getRaw('user&action=badge_status'); + + /// Use item [itemId], optionally on pokemon [pokemonId]. + AsyncVoidEither useItem(int itemId, {int? pokemonId}) => _postAction( + 'user&action=use_item', + body: {'item_id': itemId, 'pokemon_id': ?pokemonId}, + ); + + /// Buy [quantity] of shop item [itemId]. + AsyncEither buy(int itemId, int quantity) => + _postData('shop&action=buy', body: {'item_id': itemId, 'quantity': quantity}, decode: BuyResult.fromMap); + + /// Fetch a page of the pet (pokemon) shop. + AsyncEither getShopPets({int page = 1}) => + _getData('shop&action=pets&page=$page', ShopPetsPage.fromMap); + + /// Buy [quantity] of pet species [typeId]. + AsyncEither buyPet(int typeId, {int quantity = 1}) => _postData( + 'shop&action=buy_pet', + body: {'pokemon_type_id': typeId, 'quantity': quantity}, + decode: BuyPetResult.fromMap, + ); + + /// Fetch the adventure maps. + AsyncEither> getMaps() => _getData( + 'battle&action=maps', + (data) => _listOf(data['maps'], AdventureMap.fromMap), + deadline: _mapsDeadline, + ); + + /// Resume an active battle; null when there is none (the server answers 404 for that). + AsyncEither recoverBattle() => _send((headers) async { + final resp = await _net + .get('$pokemonApiBase&endpoint=battle&action=recover', options: _options(headers)) + .run(); + return switch (resp) { + Left(:final value) => left(value), + Right(:final value) => _decodeEnvelope(value).fold( + (e) => (e is PokemonApiException && e.code == 404) + ? right(null) + : left(e), + (data) => _decode(data, BattleScene.fromMap, value), + ), + }; + }); + + /// Start a battle on map [mapId], optionally against boss [bossTypeId]. + AsyncEither startBattle(int mapId, {int? bossTypeId}) => _postData( + 'battle&action=start', + body: {'map_id': mapId, 'boss_pokemon_type_id': ?bossTypeId}, + decode: BattleScene.fromMap, + ); + + /// Use skill [skillId] in battle [battleId]. + AsyncEither turn(String battleId, int skillId) => + _postData('battle&action=turn', body: {'battle_id': battleId, 'skill_id': skillId}, decode: BattleScene.fromMap); + + /// Flee battle [battleId]. + AsyncEither flee(String battleId) => + _postData('battle&action=flee', body: {'battle_id': battleId}, decode: BattleScene.fromMap); + + /// Throw the ball [ballId] at the wild pokemon. + AsyncEither capture(int ballId) => + _postData('battle&action=capture', body: {'ball_id': ballId}, decode: BattleScene.fromMap); + + /// Switch the battling pokemon (optionally to [pokemonId]). + AsyncEither switchPokemon(String battleId, {int? pokemonId}) => _postData( + 'battle&action=switch_pokemon', + body: {'battle_id': battleId, 'pokemon_id': ?pokemonId}, + decode: BattleScene.fromMap, + ); + + /// Use item [itemId] in battle; the raw `data` is either a [BattleScene] or a skill-selection response for PP items. + AsyncEither> useBattleItem(int itemId) => + _postRaw('battle&action=use_item', {'item_id': itemId}); + + /// Use PP-restore item [itemId] on skill [skillId] in battle. + AsyncEither useBattleItemOnSkill(int itemId, int skillId) => _postData( + 'battle&action=use_item_on_skill', + body: {'item_id': itemId, 'skill_id': skillId}, + decode: BattleScene.fromMap, + ); + + /// Fetch items usable in battle. + AsyncEither> getBattleItems() => + _getData('battle&action=get_battle_items', (data) => _listOf(data['items'], BattleItem.fromMap)); + + /// GET the JSON envelope of [endpoint] and decode its `data` object with [decode]. + AsyncEither _getData( + String endpoint, + T Function(Map) decode, { + Duration? deadline, + }) => _send((headers) async { + final resp = await _net + .get('$pokemonApiBase&endpoint=$endpoint', options: _options(headers)) + .run(); + return switch (resp) { + Left(:final value) => left(value), + Right(:final value) => _decodeEnvelope(value).fold((e) => left(e), (data) => _decode(data, decode, value)), + }; + }, deadline: deadline); + + /// POST [endpoint] (optionally with a JSON [body]) and ignore the response `data`. + AsyncVoidEither _postAction(String endpoint, {Map? body}) => _send((headers) async { + final resp = await _net + .postJson( + '$pokemonApiBase&endpoint=$endpoint', + data: jsonEncode(body ?? const {}), + headers: headers, + ) + .run(); + return switch (resp) { + Left(:final value) => left(value), + Right(:final value) => _decodeEnvelope(value).fold( + (e) => left(e), + (_) => right(null), + ), + }; + }); + + /// POST [endpoint] with a JSON [body] and decode its `data` object with [decode]. + AsyncEither _postData( + String endpoint, { + required Map body, + required T Function(Map) decode, + }) => _send((headers) async { + final resp = await _net + .postJson('$pokemonApiBase&endpoint=$endpoint', data: jsonEncode(body), headers: headers) + .run(); + return switch (resp) { + Left(:final value) => left(value), + Right(:final value) => _decodeEnvelope(value).fold((e) => left(e), (data) => _decode(data, decode, value)), + }; + }); + + /// Send [request] with the session formhash attached, reading it again and retrying once when the server calls it + /// stale (the plugin ties it to the session, which the forum may renew). + /// + /// The call runs under [deadline], so a request the platform client parked is reported as a network failure instead + /// of holding its page in a busy state until that client gives up on its own. + AsyncEither _send( + Future> Function(Map headers) request, { + Duration? deadline, + }) => AsyncEither( + () async { + final limit = deadline ?? _requestDeadline; + final first = await _withDeadline(request(await _formHashHeaders()), limit); + final error = first.fold((e) => e, (_) => null); + // Any other failure (including the login guard) is reported as it came. + if (error is! PokemonApiException || !(error.message ?? '').contains('formhash')) return first; + final retryHeaders = await _formHashHeaders(refresh: true); + if (retryHeaders.isEmpty) return first; + return _withDeadline(request(retryHeaders), limit); + }, + ); + + /// Run [request] under [deadline], reporting a call that never answered as a network failure. + Future> _withDeadline(Future> request, Duration deadline) => + request.timeout(deadline, onTimeout: () => left(HttpRequestFailedException(null))); + + /// Headers every plugin request needs; empty when the session's formhash could not be read. + Future> _formHashHeaders({bool refresh = false}) async { + final hash = await _ensureFormHash(refresh: refresh); + return {_formHashHeader: ?hash}; + } + + /// The session's formhash, read from a plugin page when it is not cached yet. + /// + /// A page load starts several requests at once and they all need the header, so a read that is already in flight is + /// shared instead of fetching the page once per request. + Future _ensureFormHash({bool refresh = false}) { + if (refresh) { + _formHashRead = null; + return _readFormHash(); + } + final cached = _formHash; + if (cached != null) return Future.value(cached); + return _formHashRead ??= _readFormHash(); + } + + /// Read the formhash from the plugin page and remember it; a failed read is not cached. + Future _readFormHash() async { + try { + // The page read runs under the same deadline as the call that waits for it: a parked read must not hold it. + final resp = await _net + .get(_formHashPageUrl, options: _options()) + .run() + .timeout( + _requestDeadline, + onTimeout: () => left>(HttpRequestFailedException(null)), + ); + if (resp.isLeft()) return null; + final hash = formHashOf('${resp.fold((_) => '', (value) => value.data)}'); + if (hash == null) { + warning('failed to read the formhash from $_formHashPageUrl'); + return null; + } + _formHash = hash; + return hash; + } finally { + _formHashRead = null; + } + } + + /// POST [endpoint] with a JSON [body] and return its `data` object as a raw map. + AsyncEither> _postRaw(String endpoint, Map body) => _send((headers) async { + final resp = await _net + .postJson('$pokemonApiBase&endpoint=$endpoint', data: jsonEncode(body), headers: headers) + .run(); + return switch (resp) { + Left(:final value) => left>(value), + Right(:final value) => _decodeEnvelope(value), + }; + }); + + /// GET [endpoint] and return its `data` object as a raw map. + AsyncEither> _getRaw(String endpoint) => _send((headers) async { + final resp = await _net.get('$pokemonApiBase&endpoint=$endpoint', options: _options(headers)).run(); + return switch (resp) { + Left(:final value) => left>(value), + Right(:final value) => _decodeEnvelope(value), + }; + }); + + /// Decode a `data` object with [decode], turning any parse error into a [ServerRespFailure] instead of a thrown error. + /// + /// Catches `Object` (not just `Exception`) because a wrong-typed field makes dart_mappable throw a `TypeError`, which + /// is an `Error`; letting it escape would leave the page stuck loading forever. + Either _decode(Map data, T Function(Map) decode, Response resp) { + try { + return right(decode(data)); + } on Object catch (e) { + final preview = jsonEncode(data); + talker.error('failed to decode pokemon api data: $e; data: ${preview.length > 200 ? preview.substring(0, 200) : preview}'); + return left(ServerRespFailure(status: resp.statusCode, message: '$e')); + } + } + + /// Decode the raw `{success, data, error, code}` envelope into its `data` map, or a [PokemonApiException]. + Either> _decodeEnvelope(Response resp) { + final data = resp.data; + final text = data is String ? data : jsonEncode(data); + // The plugin's login guard answers without a body (401 on older builds, 403 on newer ones), which is not JSON: + // report it as the login problem it is, so the pages show their login prompt instead of a decode error. + if (text.trim().isEmpty && (resp.statusCode == 401 || resp.statusCode == 403)) { + return left(PokemonApiException('需要登录', code: 401)); + } + try { + final decoded = jsonDecode(text); + if (decoded is! Map) { + return left(ServerRespFailure(status: resp.statusCode, message: 'unexpected pokemon api response')); + } + if (decoded['success'] != true) { + final message = decoded['error']?.toString() ?? 'pokemon api error'; + final code = (decoded['code'] as num?)?.toInt(); + // The plugin answers with this envelope for its own bugs too (for example the broken box module, which fails + // with a plain 500), so log the endpoint to make an exported log useful. A 404 is the normal "no active battle" + // answer for `recover`, so it only gets a debug line instead of a warning. + final line = + 'pokemon api error: $message (code=$code, status=${resp.statusCode}) at ${resp.requestOptions.uri}'; + if (code == 404) { + talker.debug(line); + } else { + talker.warning(line); + } + return left(PokemonApiException(message, code: code)); + } + final body = decoded['data']; + if (body == null) { + return right({}); + } + if (body is Map) { + return right(body); + } + return left(ServerRespFailure(status: resp.statusCode, message: 'unexpected pokemon api data')); + } on Object catch (e) { + talker.error( + 'failed to decode pokemon api response: $e (status=${resp.statusCode}) at ${resp.requestOptions.uri}', + ); + return left(ServerRespFailure(status: resp.statusCode, message: '$e')); + } + } + + /// Decode a JSON list of objects into a typed list. + List _listOf(Object? raw, T Function(Map) decode) { + if (raw is! List) return const []; + return raw.whereType>().map(decode).toList(); + } +} + +/// The formhash of a page of the forum: the hidden input Discuz renders for its forms, or the inline script the +/// plugin's own front-end reads the value from. +/// +/// The plugin's API rejects a request that does not carry the session's formhash in the `X-Pm-Formhash` header, so the +/// client reads it from a page of the same session. Kept pure so the two shapes stay covered by a test. +String? formHashOf(String html) { + final input = RegExp(r'formhash"\s*value="(\w+)"').firstMatch(html); + if (input != null) return input.group(1); + return RegExp(r"formhash\s*=\s*'(\w+)'").firstMatch(html)?.group(1); +} + +/// Whether the badge answer reports the forum status bar pet as hidden; null when this plugin build does not say. +/// +/// The website's status bar switch is this same `refresh_badge` call with `{"hide": bool}`; a build whose +/// `refresh_badge` predates that parameter answers without the field, so the caller can tell an old build from a +/// switch that failed. +bool? statusBarHiddenFromBadge(Map data) => data['hidden'] as bool?; diff --git a/lib/features/pokemon/repository/skill_order_store.dart b/lib/features/pokemon/repository/skill_order_store.dart new file mode 100644 index 00000000..6bdc06a9 --- /dev/null +++ b/lib/features/pokemon/repository/skill_order_store.dart @@ -0,0 +1,41 @@ +import 'dart:convert'; + +import 'package:tsdm_client/features/pokemon/models/models.dart'; +import 'package:tsdm_client/instance.dart'; +import 'package:tsdm_client/shared/providers/storage_provider/storage_provider.dart'; + +/// Local, cosmetic ordering of a pokemon's skills. +/// +/// The plugin returns a pokemon's skills in slot order and has no API to move one, so the order a player drags them +/// into is remembered on the device (keyed by the pokemon instance id) and applied whenever the list is shown. +final class SkillOrderStore { + /// Storage key of the saved order of pokemon [pokemonId]. + static String keyFor(int pokemonId) => 'pokemon.skillOrder.$pokemonId'; + + /// The saved skill ids of pokemon [pokemonId], in display order; empty when none was saved. + Future> load(int pokemonId) async { + final raw = await getIt.get().getString(keyFor(pokemonId)); + if (raw == null || raw.isEmpty) return const []; + try { + final decoded = jsonDecode(raw); + if (decoded is! List) return const []; + return decoded.whereType().map((e) => e.toInt()).toList(); + } on FormatException { + return const []; + } + } + + /// Remember the display [order] of pokemon [pokemonId]'s skills. + Future save(int pokemonId, List order) => + getIt.get().saveString(keyFor(pokemonId), jsonEncode(order)); + + /// Sort [items] into [order], reading each item's skill id with [idOf]. + /// + /// Skill ids missing from [order] keep their relative position and go last, so a newly learned skill lands at the end + /// instead of disappearing. Works for the detail page's [PokemonSkill] and the battle's skill list alike. + static List apply(List items, List order, int Function(T) idOf) { + if (order.isEmpty) return items; + final rank = {for (var i = 0; i < order.length; i++) order[i]: i}; + return [...items]..sort((a, b) => (rank[idOf(a)] ?? order.length).compareTo(rank[idOf(b)] ?? order.length)); + } +} diff --git a/lib/features/pokemon/utils/pokemon_image.dart b/lib/features/pokemon/utils/pokemon_image.dart new file mode 100644 index 00000000..63999bdf --- /dev/null +++ b/lib/features/pokemon/utils/pokemon_image.dart @@ -0,0 +1,35 @@ +import 'dart:async'; + +import 'package:tsdm_client/constants/url.dart'; +import 'package:tsdm_client/instance.dart'; +import 'package:tsdm_client/shared/providers/image_cache_provider/image_cache_provider.dart'; +import 'package:tsdm_client/shared/providers/image_cache_provider/models/models.dart'; + +/// CDN base for large pokemon sprites (my pokemon `pm/`, wild back `pmb/`). +const pokemonCdnBase = 'https://img.tsdm39.com/Pokemon'; + +/// Local images directory served by the forum (small sprites `spm/`, items `item/`). +/// +/// The X5 plugin ships them under `plugin/images`, deployed as `source/plugin/pokemon/images`; +/// the X3-era `pokemon_system/images` directory only still answers for a few subfolders. +const pokemonLocalImagesBase = '$baseUrl/source/plugin/pokemon/images'; + +/// Large sprite of the pokemon species [typeId]. +String pokemonImageUrl(int typeId) => '$pokemonCdnBase/pm/$typeId.gif'; + +/// Back-view sprite used for a wild pokemon in battle. +String pokemonBattleBackImageUrl(int typeId) => '$pokemonCdnBase/pmb/$typeId.gif'; + +/// Small sprite of the pokemon species [typeId]. +String pokemonSmallImageUrl(int typeId) => '$pokemonLocalImagesBase/spm/$typeId.gif'; + +/// Sprite of the item whose icon filename is [image] (without the `.gif` suffix). +String pokemonItemImageUrl(String image) => '$pokemonLocalImagesBase/item/$image.gif'; + +/// Warm the given sprite [urls] into the image byte cache, so the battle shows them without a fresh download. +void prefetchPokemonImages(Iterable urls) { + final cache = getIt.get(); + for (final url in urls) { + unawaited(cache.getOrMakeCache(ImageCacheGeneralRequest(url))); + } +} From 849cfcc0405902349432edc6700156a97f0c1142 Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 12:58:23 +0800 Subject: [PATCH 04/37] feat(pokemon): add the pokemon and battle cubits State and actions of the centre and of a battle, including the re-read of the battle after an action that lost its answer on the transport. --- lib/features/pokemon/cubit/battle_cubit.dart | 479 +++++++++++++ lib/features/pokemon/cubit/pokemon_cubit.dart | 630 ++++++++++++++++++ 2 files changed, 1109 insertions(+) create mode 100644 lib/features/pokemon/cubit/battle_cubit.dart create mode 100644 lib/features/pokemon/cubit/pokemon_cubit.dart diff --git a/lib/features/pokemon/cubit/battle_cubit.dart b/lib/features/pokemon/cubit/battle_cubit.dart new file mode 100644 index 00000000..d2fa3f59 --- /dev/null +++ b/lib/features/pokemon/cubit/battle_cubit.dart @@ -0,0 +1,479 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:tsdm_client/exceptions/exceptions.dart'; +import 'package:tsdm_client/features/pokemon/models/models.dart'; +import 'package:tsdm_client/features/pokemon/repository/pokemon_repository.dart'; +import 'package:tsdm_client/features/pokemon/repository/skill_order_store.dart'; +import 'package:tsdm_client/features/pokemon/utils/action_feedback.dart'; +import 'package:tsdm_client/features/pokemon/utils/item_merge.dart'; +import 'package:tsdm_client/utils/logger.dart'; + +/// Loading status of a battle. +enum BattleStatus { + /// Loading in progress. + loading, + + /// Loaded successfully. + success, + + /// Failed to load. + failure, + + /// A login is required. + needLogin, +} + +/// Result of a battle action (turn/flee/capture/switch). +final class BattleActionResult { + /// Constructor. + const BattleActionResult({required this.success, this.message}); + + /// Whether the action was accepted. + final bool success; + + /// Server message when the action failed. + final String? message; +} + +/// Result of using an item in battle; a PP item asks for a skill selection instead of resolving a scene. +final class BattleUseItemResult { + /// Constructor. + const BattleUseItemResult({this.scene, this.message, this.requiresSkillSelection = false, this.availableSkills}); + + /// Updated scene, when the item resolved immediately. + final BattleScene? scene; + + /// Error message, when the item could not be used. + final String? message; + + /// Whether a skill must be selected (PP restore items). + final bool requiresSkillSelection; + + /// Skills the PP restore can target. + final List? availableSkills; +} + +/// Immutable state of a battle. +final class BattleState { + /// Constructor. + const BattleState({ + this.status = BattleStatus.loading, + this.scene, + this.wildGender, + this.wildShiny, + this.skillOrder = const [], + this.turn = 1, + this.actionInProgress = false, + this.failureMessage, + }); + + /// Loading status. + final BattleStatus status; + + /// Current battle scene. + final BattleScene? scene; + + /// Gender of the wild pokemon from the last running turn (0=雄性, 1=雌性). + /// + /// The server clears `allure`, which carries the gender, when the battle ends, so the finished scene reports 0; + /// remembering the last running value stops the opponent from flipping to male on the final hit. + final int? wildGender; + + /// Shiny state of the wild pokemon from the last running turn. + final bool? wildShiny; + + /// Device-local display order of the battling pokemon's skills (skill ids, empty until loaded). + /// + /// The plugin returns skills in slot order only; the order the player dragged them into on the detail page is applied + /// here so the battle buttons show the same thing. + final List skillOrder; + + /// Round the battle is in, counted by the client. + /// + /// The plugin has no turn counter: it answers `turn: 1` while a battle is active and `0` once it is over, so the + /// number shown is the client's own count of accepted turns and starts again at 1 after a resume. + final int turn; + + /// Whether an action is in flight. + final bool actionInProgress; + + /// Error message of the last failed load, shown in the failure state. + final String? failureMessage; + + /// Copy with the given fields. + BattleState copyWith({ + BattleStatus? status, + BattleScene? scene, + int? wildGender, + bool? wildShiny, + List? skillOrder, + int? turn, + bool? actionInProgress, + String? failureMessage, + }) => BattleState( + status: status ?? this.status, + scene: scene ?? this.scene, + wildGender: wildGender ?? this.wildGender, + wildShiny: wildShiny ?? this.wildShiny, + skillOrder: skillOrder ?? this.skillOrder, + turn: turn ?? this.turn, + actionInProgress: actionInProgress ?? this.actionInProgress, + failureMessage: failureMessage ?? this.failureMessage, + ); +} + +/// What a resume found out about a battle that was in progress. +enum BattleResumeResult { + /// Nothing was in flight when the app came back. + nothing, + + /// The server still has the battle, whose current scene was loaded again. + refreshed, + + /// The server no longer has the battle: it ended while the app was away. + gone, + + /// The battle could not be read again (network error). + failed, +} + +/// Cubit of a battle: starts/renews it and runs turn/action requests. +class BattleCubit extends Cubit with LoggerMixin { + /// [repository] and [skillOrder] are injectable for tests. + BattleCubit({PokemonRepository? repository, SkillOrderStore? skillOrder}) + : _repository = repository ?? PokemonRepository(), + _skillOrder = skillOrder ?? SkillOrderStore(), + super(const BattleState()); + + final PokemonRepository _repository; + + final SkillOrderStore _skillOrder; + + /// Bumped when a resume invalidates the answers of requests that were still in flight. + int _generation = 0; + + /// Round counter of the current battle; the plugin itself does not count turns. + int _turn = 1; + + /// Start a new battle on map [mapId], optionally against boss [bossTypeId]. + /// + /// With [keepScene] and an existing scene, the current scene stays on screen (the UI only disables its buttons) while + /// the new battle starts, instead of flashing a full-page spinner. A refusal that asks for healing is answered by + /// healing the party once and retrying, which [retryAfterHeal] guards against looping. + Future start(int mapId, {int? bossTypeId, bool keepScene = false, bool retryAfterHeal = true}) async { + final keep = keepScene && state.scene != null; + final generation = _generation; + _turn = 1; + emit(state.copyWith(status: keep ? null : BattleStatus.loading, actionInProgress: keep, turn: _turn)); + final result = await _repository.startBattle(mapId, bossTypeId: bossTypeId).run(); + if (isClosed) return; + switch (result) { + case Left(:final value): + // A fainted or abnormal battling pet is refused before the fight starts: heal the party (free) and try once + // more, so the player does not have to walk to the centre for something the client can do itself. + if (retryAfterHeal && petNeedsHealingError(value)) { + await healParty(); + if (isClosed) return; + await start(mapId, bossTypeId: bossTypeId, keepScene: keepScene, retryAfterHeal: false); + return; + } + emit( + state.copyWith( + status: _isNeedLogin(value) ? BattleStatus.needLogin : BattleStatus.failure, + failureMessage: _messageOf(value), + actionInProgress: false, + ), + ); + case Right(:final value): + _emitSceneIfCurrent(value, generation, status: BattleStatus.success, actionInProgress: false); + } + } + + /// Resume an already-active battle from [scene]. + void resume(BattleScene scene) { + _emitScene(scene, status: BattleStatus.success); + } + + /// Use skill [skillId]. + Future useSkill(int skillId) => _runAction(() => _repository.turn(state.scene!.battleId, skillId)); + + /// Flee the battle. + Future flee() => _runAction(() => _repository.flee(state.scene!.battleId)); + + /// Flee the battle and heal the battling pokemon at the center. + Future healAndFlee() async { + final instanceId = state.scene!.myPokemon.instanceId; + // Without the instance id the request would ask the server to heal pokemon 0; fail early instead. + if (instanceId <= 0) { + return const BattleActionResult(success: false); + } + emit(state.copyWith(actionInProgress: true)); + try { + final result = await _repository.healAndFlee(instanceId).run(); + return result.fold( + (e) => BattleActionResult(success: false, message: _messageOf(e)), + (heal) => BattleActionResult(success: true, message: heal.message), + ); + } on Exception catch (e) { + return BattleActionResult(success: false, message: '$e'); + } finally { + if (!isClosed) emit(state.copyWith(actionInProgress: false)); + } + } + + /// Best-effort refresh of the forum-side pokemon badge after a battle that changed the party. + /// + /// The forum keeps its own serialized copy of the party, so the badge (and the level shown under the avatar) would + /// stay stale after a level up or a capture otherwise. + Future refreshBadge() async { + await _repository.refreshBadge().run(); + } + + /// Heal the battling pokemon at the center. + /// + /// The heal is free on the server and only allowed once the battle is over, so the battle result calls it before + /// leaving the page. The plugin never heals a pokemon on its own — a fight the player leaves without this stays + /// unhealed — which is why every way out of the battle page has to go through it. + Future healPet(int instanceId) async { + final result = await _repository.heal(instanceId).run(); + if (isClosed) return const BattleActionResult(success: false); + return result.fold( + (e) => BattleActionResult(success: false, message: _messageOf(e)), + (heal) => BattleActionResult(success: true, message: heal.message), + ); + } + + /// Heal every carried pokemon that needs it once a battle is over; returns how many were healed. + /// + /// The server heal is free and restores HP, the skill PP and abnormal states; working from the list instead of the + /// finished scene means a pet the scene named wrongly — or one that sat the fight out — is healed as well. The heals + /// run together, so a party that spent PP during the fight costs a single round trip. + Future healParty() => _repository.healParty(); + + /// Fight again on [mapId]: acknowledge the tap at once, heal the pet that just fought, then start the next battle. + /// + /// The busy state comes first so the page reacts to the tap while the heal runs; the heal used to happen before any + /// feedback, which read as a frozen button. Only the pet in the current scene is healed — the scene carries its HP and + /// PP, so this needs no extra list request — and the rest of the party is taken care of when the player leaves. + Future fightAgain(int mapId, {int? bossTypeId}) async { + emit(state.copyWith(actionInProgress: true)); + final scene = state.scene; + if (scene != null && _scenePetNeedsHealing(scene)) { + await healPet(scene.myPokemon.instanceId); + if (isClosed) return; + } + await start(mapId, bossTypeId: bossTypeId, keepScene: true); + } + + /// Whether the pokemon in [scene] should be healed before another fight: hurt, or out of skill PP. + /// + /// The scene does not carry the pet's state, so the abnormal states are left to the refusal-and-retry path of [start]. + static bool _scenePetNeedsHealing(BattleScene scene) => + scene.myPokemon.hp < scene.myPokemon.maxHp || + scene.myPokemon.skills.any((skill) => skill.pp < skill.maxPp); + + /// Handle the app coming back to the foreground. + /// + /// A request the OS parked while the app was away can hold the page in its busy state for a long time, so stop that + /// wait and read the battle again from the server. The answers of requests that were in flight are dropped. + Future onAppResumed() async { + if (!state.actionInProgress) return BattleResumeResult.nothing; + _generation++; + emit(state.copyWith(actionInProgress: false)); + final result = await _repository.recoverBattle().run(); + if (isClosed) return BattleResumeResult.failed; + if (result.isLeft()) return BattleResumeResult.failed; + final scene = result.fold((_) => null, (value) => value); + if (scene == null) { + // The battle ended while the app was away: heal whatever the party needs, since nothing else will. + unawaited(healParty()); + return BattleResumeResult.gone; + } + _emitScene(scene, status: BattleStatus.success, actionInProgress: false); + return BattleResumeResult.refreshed; + } + + /// Read the battle again after an action failed on the transport. + /// + /// The answer of the failed action never arrived, so what it did server-side is unknown (a turn, a switch, a capture + /// may or may not have happened); the page has to show the server's own scene instead of the one the player acted on. + /// A battle the server no longer has is left alone: the page's own handling reports that on the next action. + Future resync() async { + final result = await _repository.recoverBattle().run(); + if (isClosed || result.isLeft()) return; + final scene = result.fold((_) => null, (value) => value); + if (scene == null) return; + _emitScene(scene, status: BattleStatus.success, actionInProgress: false); + } + + /// Throw the ball [ballId]. + Future capture(int ballId) => _runAction(() => _repository.capture(ballId)); + + /// Switch to another pokemon, optionally choosing [pokemonId]. + Future switchPokemon({int? pokemonId}) => + _runAction(() => _repository.switchPokemon(state.scene!.battleId, pokemonId: pokemonId)); + + /// Use PP-restore item [itemId] on skill [skillId]. + Future useItemOnSkill(int itemId, int skillId) => + _runAction(() => _repository.useBattleItemOnSkill(itemId, skillId)); + + /// Use item [itemId] in battle; returns the scene or asks for a skill selection. + Future useItem(int itemId) async { + final generation = _generation; + emit(state.copyWith(actionInProgress: true)); + try { + final result = await _repository.useBattleItem(itemId).run(); + return result.fold( + (e) => BattleUseItemResult(message: _messageOf(e)), + (data) { + if (data['requires_skill_selection'] == true) { + return BattleUseItemResult( + requiresSkillSelection: true, + availableSkills: _listOf(data['available_skills'], BattleSkill.fromMap), + ); + } + try { + final scene = BattleScene.fromMap(data); + if (scene.isActive) _turn++; + if (!isClosed) _emitSceneIfCurrent(scene, generation); + return BattleUseItemResult(scene: scene); + } on Exception { + return const BattleUseItemResult(message: 'unexpected battle item response'); + } + }, + ); + } on Exception catch (e) { + return BattleUseItemResult(message: '$e'); + } finally { + if (!isClosed) emit(state.copyWith(actionInProgress: false)); + } + } + + /// Fetch items usable in battle. + Future> loadBattleItems() async { + final result = await _repository.getBattleItems().run(); + return result.fold((_) => const [], (items) => items); + } + + /// Fetch the balls (type=2 items) the player owns, merged by type. + Future> loadBalls() async { + final result = await _repository.getInventory(type: 2).run(); + return result.fold((_) => const [], (page) => mergeInventoryItems(page.items)); + } + + /// Fetch the player's pokemon list (for the switch picker). + Future> loadPokemons() async { + final result = await _repository.getMyPokemon().run(); + return result.fold((_) => const [], (pokemons) => pokemons); + } + + /// Run a battle action that resolves a [BattleScene]. + Future _runAction(AsyncEither Function() action) async { + final generation = _generation; + emit(state.copyWith(actionInProgress: true)); + try { + final result = await action().run(); + return result.fold( + (e) { + // The answer never arrived, so the scene on screen may no longer be the server's: read it again instead of + // leaving the player acting on a stale one. + if (_messageOf(e) == null) unawaited(resync()); + return BattleActionResult(success: false, message: _messageOf(e)); + }, + (scene) { + // A turn the server accepted and that left the battle running is one more round; ending it does not count. + if (scene.isActive) _turn++; + if (!isClosed) _emitSceneIfCurrent(scene, generation); + return const BattleActionResult(success: true); + }, + ); + } on Exception catch (e) { + return BattleActionResult(success: false, message: '$e'); + } finally { + if (!isClosed) emit(state.copyWith(actionInProgress: false)); + } + } + + /// Emit [scene], remembering the wild pokemon's gender and shiny state while the battle is still running. + /// + /// A finished scene reports the gender the server reset when it cleared the battle state, so the last running values + /// are kept in the state for the view to fall back on. + void _emitScene(BattleScene scene, {BattleStatus? status, bool? actionInProgress}) { + // Most callers reach this from an answer that was in flight; the page may be gone by then. + if (isClosed) return; + final otherPokemon = scene.myPokemon.instanceId != state.scene?.myPokemon.instanceId; + emit( + state.copyWith( + status: status, + scene: scene, + wildGender: scene.isActive ? scene.wildPokemon.gender : null, + wildShiny: scene.isActive ? scene.wildPokemon.isShiny : null, + turn: _turn, + actionInProgress: actionInProgress, + ), + ); + // The device-local skill order belongs to one pokemon, so re-read it whenever the battling one changes. + if (otherPokemon) unawaited(_loadSkillOrder(scene.myPokemon.instanceId)); + } + + /// Emit [scene] unless the app resumed while the answer was in flight, which made that answer stale. + void _emitSceneIfCurrent(BattleScene scene, int generation, {BattleStatus? status, bool? actionInProgress}) { + if (generation != _generation) return; + _emitScene(scene, status: status, actionInProgress: actionInProgress); + } + + /// Load the device-local skill order of pokemon [instanceId] into the state. + Future _loadSkillOrder(int instanceId) async { + final List order; + try { + order = await _skillOrder.load(instanceId); + } on Object { + // The device-local skill order is a nicety: a storage that cannot be read must never take the battle down. + return; + } + if (isClosed) return; + emit(state.copyWith(skillOrder: order)); + } + + /// End a battle the player lost, so the server stops keeping it active. + /// + /// The plugin answers `status: 'defeat'` **without** clearing the battle while the player still owns a usable backup + /// pokemon (`battle.php`: only the no-backup case calls `clear_battle_state`), so the adventure page would resume the + /// finished battle and the player had to flee by hand. `heal_and_flee` clears the battle unconditionally and heals + /// the pokemon for free, so it goes first; the randomized `flee` is the fallback. + Future finishDefeat(BattleScene scene) async { + final healed = await _repository.healAndFlee(scene.myPokemon.instanceId).run(); + if (isClosed) return const BattleActionResult(success: false); + if (healed.isRight()) { + return healed.fold( + (_) => const BattleActionResult(success: true), + (data) => BattleActionResult(success: true, message: data.message), + ); + } + // The server had already cleared the battle (no usable backup, or another client ended it): heal directly, so the + // pet is restored even when there is nothing left to end. + final healError = healed.fold((e) => e, (_) => null); + if (battleAlreadyOverError(healError)) { + return healPet(scene.myPokemon.instanceId); + } + final fled = await _repository.flee(scene.battleId).run(); + if (isClosed) return const BattleActionResult(success: false); + return fled.fold( + (e) => BattleActionResult(success: false, message: _messageOf(e)), + (fledScene) => BattleActionResult(success: true, message: fledScene.message), + ); + } + + /// Message of a pokemon api failure: only the server's own business message, so the UI can show a friendly hint + /// for network/timeout errors instead of raw exception text. + String? _messageOf(AppException e) => e is PokemonApiException ? e.message : null; + + bool _isNeedLogin(AppException e) => e is PokemonApiException && e.code == 401; + + List _listOf(Object? raw, T Function(Map) decode) { + if (raw is! List) return const []; + return raw.whereType>().map(decode).toList(); + } +} diff --git a/lib/features/pokemon/cubit/pokemon_cubit.dart b/lib/features/pokemon/cubit/pokemon_cubit.dart new file mode 100644 index 00000000..b96c2108 --- /dev/null +++ b/lib/features/pokemon/cubit/pokemon_cubit.dart @@ -0,0 +1,630 @@ +import 'package:bloc/bloc.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:tsdm_client/exceptions/exceptions.dart'; +import 'package:tsdm_client/features/pokemon/models/models.dart'; +import 'package:tsdm_client/features/pokemon/repository/pokemon_repository.dart'; + +/// Loading status of the pokemon center. +enum PokemonStatus { + /// Loading in progress. + loading, + + /// Loaded successfully. + success, + + /// Failed to load. + failure, + + /// A login is required. + needLogin, +} + +/// Result of a state-changing pokemon action. +final class PokemonActionResult { + /// Constructor. + const PokemonActionResult({required this.success, this.message}); + + /// Whether the server accepted the action. + final bool success; + + /// Human-readable server message, when supplied. + final String? message; +} + +/// What happened when the player asked to show or hide the forum status bar pet. +/// +/// The page turns it into a localized message; the cubit has no context to localize with. +enum StatusBarOutcome { + /// The pet was removed from the status bar. + hidden, + + /// The party was written back into the status bar (shown again). + shown, + + /// The session is no longer valid. + notLoggedIn, + + /// The answer says the action did not take effect. + notApplied, + + /// The request itself failed (network or timeout). + failed, +} + +/// A shop category: an item `type` (1-5) or the pet listing. +enum ShopCategory { + /// All item categories. + all(null), + + /// Recovery items. + med(1), + + /// Poke balls. + ball(2), + + /// Evolution stones. + stone(3), + + /// Boost items. + boost(4), + + /// Equipment. + equipment(5), + + /// Pokemon for sale. + pet(null, isPet: true); + + const ShopCategory(this.type, {this.isPet = false}); + + /// The item `type` query parameter, or null for all/pets. + final int? type; + + /// Whether this category lists pokemon instead of items. + final bool isPet; +} + +/// Immutable state of the pokemon center. +final class PokemonState { + /// Constructor. + const PokemonState({ + this.status = PokemonStatus.loading, + this.config, + this.pokemons, + this.profile, + this.inventory, + this.shop, + this.shopPets, + this.shopCategory = ShopCategory.all, + this.inventoryCategory = ShopCategory.all, + this.inventoryPage = 1, + this.shopPage = 1, + this.actionInProgress = false, + }); + + /// Overall loading status. + final PokemonStatus status; + + /// Global plugin config. + final GlobalConfig? config; + + /// Current user's pokemon list. + final List? pokemons; + + /// Current user's plugin profile (money etc.). + final PokemonUserProfile? profile; + + /// Loaded inventory page. + final InventoryPage? inventory; + + /// Loaded shop page. + final ShopPage? shop; + + /// Loaded pet shop page. + final ShopPetsPage? shopPets; + + /// Currently selected shop category. + final ShopCategory shopCategory; + + /// Currently selected inventory category. + final ShopCategory inventoryCategory; + + /// Current inventory page number. + final int inventoryPage; + + /// Current shop page number. + final int shopPage; + + /// Whether a state-changing action is running. + final bool actionInProgress; + + /// Copy with the given fields. + PokemonState copyWith({ + PokemonStatus? status, + GlobalConfig? config, + List? pokemons, + PokemonUserProfile? profile, + InventoryPage? inventory, + ShopPage? shop, + ShopPetsPage? shopPets, + ShopCategory? shopCategory, + ShopCategory? inventoryCategory, + bool clearShop = false, + bool clearPets = false, + bool clearInventory = false, + int? inventoryPage, + int? shopPage, + bool? actionInProgress, + }) => PokemonState( + status: status ?? this.status, + config: config ?? this.config, + pokemons: pokemons ?? this.pokemons, + profile: profile ?? this.profile, + inventory: clearInventory ? null : (inventory ?? this.inventory), + shop: clearShop ? null : (shop ?? this.shop), + shopPets: clearPets ? null : (shopPets ?? this.shopPets), + shopCategory: shopCategory ?? this.shopCategory, + inventoryCategory: inventoryCategory ?? this.inventoryCategory, + inventoryPage: inventoryPage ?? this.inventoryPage, + shopPage: shopPage ?? this.shopPage, + actionInProgress: actionInProgress ?? this.actionInProgress, + ); +} + +/// Cubit of the pokemon center, loading the plugin JSON API and running actions. +class PokemonCubit extends Cubit { + /// [repository] is injectable for tests. + PokemonCubit({PokemonRepository? repository}) + : _repository = repository ?? PokemonRepository(), + super(const PokemonState()); + + final PokemonRepository _repository; + + /// Status bar state the player asked for and the server has not confirmed yet, or null when there is none. + /// + /// A switch survives the app going to the background, so [reconcileStatusBar] can finish it on the way back. + bool? _pendingStatusBar; + + /// Guards against two page loads for the same slice at once. + /// + /// The scroll listener and the search's `loadAll*` can ask for the next page together; without the guard both would + /// append the same page and the inventory's merged quantities would double. + bool _loadingMoreInventory = false; + bool _loadingMoreShop = false; + + /// Reload everything; starts all requests concurrently so the first screen only waits for the slowest one. + Future load() async { + emit(state.copyWith(status: PokemonStatus.loading)); + + final configFuture = _repository.getGlobalConfig().run(); + final pokemonsFuture = _repository.getMyPokemon().run(); + final profileFuture = _repository.getProfile().run(); + final inventoryFuture = _repository.getInventory( + page: state.inventoryPage, + type: state.inventoryCategory.type, + ).run(); + final shopFuture = _repository.getShop(page: state.shopPage).run(); + + final config = await configFuture; + final pokemons = await pokemonsFuture; + final profile = await profileFuture; + final inventory = await inventoryFuture; + final shop = await shopFuture; + + final results = >[config, pokemons, profile, inventory, shop]; + // The five requests outlive the page when the player leaves while they are in flight; a closed cubit cannot emit. + if (isClosed) return; + if (results.any(_isNeedLogin)) { + emit(state.copyWith(status: PokemonStatus.needLogin)); + return; + } + if (results.any((e) => e.isLeft())) { + emit(state.copyWith(status: PokemonStatus.failure)); + return; + } + + emit( + state.copyWith( + status: PokemonStatus.success, + config: (config as Right).value, + pokemons: (pokemons as Right>).value, + profile: (profile as Right).value, + inventory: (inventory as Right).value, + shop: (shop as Right).value, + ), + ); + // The profile can lag behind the actual status bar pet (or not carry the flag at all), so read it like the website. + await _syncStatusBarState(); + } + + /// Refresh every slice in place, without the full-page spinner; individual failures are ignored. + Future refreshAll() async { + await Future.wait([refreshConfig(), refreshPokemons(), refreshProfile(), refreshInventory(), refreshShop()]); + } + + /// Refresh the global config in place. + Future refreshConfig() async { + final result = await _repository.getGlobalConfig().run(); + if (isClosed) return; + result.fold((_) => null, (config) => emit(state.copyWith(config: config))); + } + + /// Refresh the pokemon list in place. + Future refreshPokemons() async { + final result = await _repository.getMyPokemon().run(); + if (isClosed) return; + result.fold((_) => null, (pokemons) => emit(state.copyWith(pokemons: pokemons))); + } + + /// Refresh the user profile in place. + Future refreshProfile() async { + final result = await _repository.getProfile().run(); + if (isClosed) return; + result.fold((_) => null, (profile) => emit(state.copyWith(profile: profile))); + } + + /// Refresh the forum-side pokemon badge; returns the server message. + /// + /// The forum keeps its own serialized copy of the party, so the badge (and the level under the avatar) stays stale + /// until this is called. + Future refreshBadge() async { + // The badge and the status bar pet are one row on the forum, so keep the pet hidden when the profile says it is. + final result = await _repository.refreshBadge(hide: state.profile?.statusBarHidden ?? false).run(); + return result.fold( + (e) => PokemonActionResult(success: false, message: e is PokemonApiException ? e.message : null), + (data) => PokemonActionResult(success: true, message: data['message']?.toString()), + ); + } + + /// Set the forum status bar pet (hide it, or refresh/show it) with the call the website itself makes. + /// + /// `refresh_badge` answers `{message, hidden}`; a plugin build whose `refresh_badge` predates the `hide` parameter + /// answers without that field, which is reported as `notApplied` instead of pretending the switch worked. + /// + /// The switch flips right away: the call takes seconds, and the platform client parks a request the OS suspends, so a + /// player who leaves the app before the answer lands would otherwise see nothing happen. Such a switch stays + /// [_pendingStatusBar] and [reconcileStatusBar] finishes it when the app comes back. + Future setStatusBar({required bool hide}) async { + _pendingStatusBar = hide; + _emitStatusBarHidden(hide); + final result = await _repository.refreshBadge(hide: hide).run(); + if (isClosed) return StatusBarOutcome.failed; + if (result.isLeft()) { + final error = result.fold((e) => e, (_) => null); + _pendingStatusBar = null; + // Put the switch back to whatever the server still has. + await refreshProfile(); + return error is PokemonApiException && error.code == 401 + ? StatusBarOutcome.notLoggedIn + : StatusBarOutcome.failed; + } + final data = result.fold((_) => const {}, (value) => value); + // Hiding must be confirmed by the answer; refreshing is what the request itself does. + final applied = !hide || statusBarHiddenFromBadge(data) == true; + if (applied) { + _pendingStatusBar = null; + } + await refreshProfile(); + if (isClosed) return StatusBarOutcome.failed; + if (!applied) return StatusBarOutcome.notApplied; + return hide ? StatusBarOutcome.hidden : StatusBarOutcome.shown; + } + + /// Finish a status bar switch that was still in flight when the app went to the background, and read the real state + /// back; called when the app comes back to the foreground. + Future reconcileStatusBar() async { + final pending = _pendingStatusBar; + _pendingStatusBar = null; + if (pending != null) { + await setStatusBar(hide: pending); + return; + } + await refreshProfile(); + await _syncStatusBarState(); + } + + /// Show [hidden] in the profile the status bar switch reads, until the server confirms the change. + void _emitStatusBarHidden(bool hidden) { + final profile = state.profile; + if (profile == null || isClosed) return; + emit(state.copyWith(profile: profile.copyWith(statusBarHidden: hidden))); + } + + /// Read the status bar state the way the website does (`user&action=badge_status`) and put it in the profile the + /// switch reads; a plugin build without that action keeps whatever the profile said. + Future _syncStatusBarState() async { + final result = await _repository.badgeStatus().run(); + if (isClosed || result.isLeft()) return; + final hidden = statusBarHiddenFromBadge(result.fold((_) => const {}, (value) => value)); + final profile = state.profile; + if (hidden == null || profile == null || profile.statusBarHidden == hidden) return; + emit(state.copyWith(profile: profile.copyWith(statusBarHidden: hidden))); + } + + /// Refresh the inventory in place, for the currently selected category. + Future refreshInventory() async { + final result = await _repository.getInventory(page: state.inventoryPage, type: state.inventoryCategory.type).run(); + if (isClosed) return; + result.fold((_) => null, (inventory) => emit(state.copyWith(inventory: inventory))); + } + + /// Switch the inventory to [category] and load its first page. + Future setInventoryCategory(ShopCategory category) async { + emit(state.copyWith(inventoryCategory: category, inventoryPage: 1, clearInventory: true)); + await refreshInventory(); + } + + /// Refresh the shop in place, for the currently selected category. + Future refreshShop() async { + if (state.shopCategory.isPet) { + final result = await _repository.getShopPets(page: state.shopPage).run(); + if (isClosed) return; + result.fold((_) => null, (pets) => emit(state.copyWith(shopPets: pets))); + return; + } + final result = await _repository.getShop(page: state.shopPage, type: state.shopCategory.type).run(); + if (isClosed) return; + result.fold((_) => null, (shop) => emit(state.copyWith(shop: shop))); + } + + /// Switch the shop to [category] and load its first page. + Future setShopCategory(ShopCategory category) async { + emit(state.copyWith(shopCategory: category, shopPage: 1, clearShop: true, clearPets: true)); + await refreshShop(); + } + + /// Load the next page of the inventory, for the currently selected category. + Future loadMoreInventory() async { + final current = state.inventory; + if (_loadingMoreInventory || current == null || state.inventoryPage >= current.totalPages) return; + _loadingMoreInventory = true; + try { + final next = state.inventoryPage + 1; + final result = await _repository.getInventory(page: next, type: state.inventoryCategory.type).run(); + if (isClosed) return; + result.fold( + (_) => null, + (page) => emit( + state.copyWith( + inventory: InventoryPage( + items: [...current.items, ...page.items], + total: page.total, + page: page.page, + perPage: page.perPage, + totalPages: page.totalPages, + ), + inventoryPage: next, + ), + ), + ); + } finally { + _loadingMoreInventory = false; + } + } + + /// Load every remaining page of the inventory, so a search covers all items instead of only the loaded page. + Future loadAllInventory() async { + var guard = 0; + while (!isClosed && guard < 50 && state.inventoryPage < (state.inventory?.totalPages ?? 1)) { + guard++; + final before = state.inventoryPage; + await loadMoreInventory(); + if (state.inventoryPage == before) break; + } + } + + /// Load the next page of the shop, for the currently selected category (items or pets). + Future loadMoreShop() async { + if (_loadingMoreShop) return; + final totalPages = state.shopCategory.isPet ? (state.shopPets?.totalPages ?? 1) : (state.shop?.totalPages ?? 1); + if (state.shopPage >= totalPages) return; + _loadingMoreShop = true; + try { + if (state.shopCategory.isPet) { + final current = state.shopPets; + if (current == null) return; + final next = state.shopPage + 1; + final result = await _repository.getShopPets(page: next).run(); + if (isClosed) return; + result.fold( + (_) => null, + (page) => emit( + state.copyWith( + shopPets: ShopPetsPage( + pets: [...current.pets, ...page.pets], + total: page.total, + page: page.page, + perPage: page.perPage, + totalPages: page.totalPages, + ), + shopPage: next, + ), + ), + ); + return; + } + final current = state.shop; + if (current == null) return; + final next = state.shopPage + 1; + final result = await _repository.getShop(page: next, type: state.shopCategory.type).run(); + if (isClosed) return; + result.fold( + (_) => null, + (page) => emit( + state.copyWith( + shop: ShopPage( + items: [...current.items, ...page.items], + total: page.total, + page: page.page, + perPage: page.perPage, + totalPages: page.totalPages, + ), + shopPage: next, + ), + ), + ); + } finally { + _loadingMoreShop = false; + } + } + + /// Load every remaining page of the shop, so a search covers all entries instead of only the loaded page. + Future loadAllShop() async { + var guard = 0; + while (!isClosed && guard < 50) { + final totalPages = state.shopCategory.isPet ? (state.shopPets?.totalPages ?? 1) : (state.shop?.totalPages ?? 1); + if (state.shopPage >= totalPages) break; + guard++; + final before = state.shopPage; + await loadMoreShop(); + if (state.shopPage == before) break; + } + } + + /// Set pokemon [id] as the first pokemon. + Future setFirst(int id) async { + final result = await _runVoid(() => _repository.setFirst(id)); + if (result.success) await refreshPokemons(); + return result; + } + + /// Move pokemon [id] to [site] (1=first, 2=bag, 3=storage). + Future movePokemon(int id, int site) async { + final result = await _runVoid(() => _repository.movePokemon(id, site)); + if (result.success) await refreshPokemons(); + return result; + } + + /// Rename pokemon [id] to [name]. + Future rename(int id, String name) async { + final result = await _runVoid(() => _repository.rename(id, name)); + if (result.success) await refreshPokemons(); + return result; + } + + /// Release pokemon [id]. + Future release(int id) async { + final result = await _runVoid(() => _repository.release(id)); + if (result.success) await refreshPokemons(); + return result; + } + + /// Heal pokemon [id]. + Future heal(int id) => _heal(id, flee: false); + + /// Flee any active battle and heal pokemon [id]. + Future healAndFlee(int id) => _heal(id, flee: true); + + Future _heal(int id, {required bool flee}) async { + // Optimistically heal the pokemon in place so the UI reacts immediately. + final pokemon = _findPokemon(id); + if (pokemon != null) { + _replacePokemon(_healedCopy(pokemon)); + } + emit(state.copyWith(actionInProgress: true)); + try { + final result = await (flee ? _repository.healAndFlee(id) : _repository.heal(id)).run(); + final actionResult = switch (result) { + Left(:final value) => PokemonActionResult(success: false, message: _messageOf(value)), + Right(:final value) => PokemonActionResult(success: true, message: value.message), + }; + if (!actionResult.success) await refreshPokemons(); + return actionResult; + } on Exception catch (e) { + return PokemonActionResult(success: false, message: '$e'); + } finally { + if (!isClosed) emit(state.copyWith(actionInProgress: false)); + } + } + + /// Use item [itemId], optionally on pokemon [pokemonId]. + Future useItem(int itemId, {int? pokemonId}) async { + final result = await _runVoid(() => _repository.useItem(itemId, pokemonId: pokemonId)); + if (result.success) await Future.wait([refreshInventory(), refreshPokemons()]); + return result; + } + + /// Buy [quantity] of shop item [itemId]. + Future buy(int itemId, int quantity) async { + emit(state.copyWith(actionInProgress: true)); + try { + final result = await _repository.buy(itemId, quantity).run(); + final actionResult = switch (result) { + Left(:final value) => PokemonActionResult(success: false, message: _messageOf(value)), + Right(:final value) => PokemonActionResult(success: true, message: value.message), + }; + if (actionResult.success) await Future.wait([refreshShop(), refreshProfile()]); + return actionResult; + } on Exception catch (e) { + return PokemonActionResult(success: false, message: '$e'); + } finally { + if (!isClosed) emit(state.copyWith(actionInProgress: false)); + } + } + + /// Buy [quantity] of pet species [typeId]. + Future buyPet(int typeId, {int quantity = 1}) async { + emit(state.copyWith(actionInProgress: true)); + try { + final result = await _repository.buyPet(typeId, quantity: quantity).run(); + final actionResult = switch (result) { + Left(:final value) => PokemonActionResult(success: false, message: _messageOf(value)), + Right(:final value) => PokemonActionResult(success: true, message: value.message), + }; + if (actionResult.success) await Future.wait([refreshShop(), refreshProfile(), refreshPokemons()]); + return actionResult; + } on Exception catch (e) { + return PokemonActionResult(success: false, message: '$e'); + } finally { + if (!isClosed) emit(state.copyWith(actionInProgress: false)); + } + } + + /// Run a void action and report success/failure. + Future _runVoid(AsyncVoidEither Function() action) async { + emit(state.copyWith(actionInProgress: true)); + try { + final result = await action().run(); + return switch (result) { + Left(:final value) => PokemonActionResult(success: false, message: _messageOf(value)), + Right() => const PokemonActionResult(success: true), + }; + } on Exception catch (e) { + return PokemonActionResult(success: false, message: '$e'); + } finally { + if (!isClosed) emit(state.copyWith(actionInProgress: false)); + } + } + + /// Find a pokemon by id in the current state, or null. + Pokemon? _findPokemon(int id) { + final pokemons = state.pokemons; + if (pokemons == null) return null; + for (final pokemon in pokemons) { + if (pokemon.id == id) return pokemon; + } + return null; + } + + /// Replace the pokemon with the same id as [updated] in the current state. + void _replacePokemon(Pokemon updated) { + final pokemons = state.pokemons; + if (pokemons == null) return; + emit(state.copyWith(pokemons: [for (final pokemon in pokemons) pokemon.id == updated.id ? updated : pokemon])); + } + + /// Optimistically healed copy of [pokemon]: HP to max, and a negative state reset to normal. + /// + /// The views read the state code rather than the server's Chinese `state_text`, so the state itself is all there is + /// to reset here. + Pokemon _healedCopy(Pokemon pokemon) { + final reset = pokemon.hp <= 0 || pokemon.isNegativeState; + return pokemon.copyWith(hp: pokemon.maxHp, state: reset ? 1 : pokemon.state); + } + + /// Message of a pokemon api failure: only the server's own business message, so the UI can show a friendly hint + /// for network/timeout errors instead of raw exception text. + String? _messageOf(AppException e) => e is PokemonApiException ? e.message : null; + + bool _isNeedLogin(Either e) => + e is Left && e.value is PokemonApiException && (e.value as PokemonApiException).code == 401; +} From 637ba61408c54b77f2db450fa4fe019940a2fef4 Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 12:58:28 +0800 Subject: [PATCH 05/37] feat(pokemon): add pages, tabs and widgets The centre with its four tabs, the adventure map list, the battle page and view, the detail/equipment/storage pages, and the shared widgets and helpers (state and type styling, dialogs, item paging). --- .../pokemon/utils/action_feedback.dart | 82 +++ lib/features/pokemon/utils/item_merge.dart | 23 + .../pokemon/utils/pokemon_dialogs.dart | 135 +++++ lib/features/pokemon/utils/pokemon_style.dart | 150 +++++ lib/features/pokemon/view/adventure_page.dart | 554 ++++++++++++++++++ lib/features/pokemon/view/battle_page.dart | 512 ++++++++++++++++ lib/features/pokemon/view/battle_view.dart | 375 ++++++++++++ lib/features/pokemon/view/inventory_tab.dart | 216 +++++++ lib/features/pokemon/view/my_pokemon_tab.dart | 155 +++++ .../pokemon/view/pokemon_center_tab.dart | 127 ++++ .../pokemon/view/pokemon_detail_page.dart | 475 +++++++++++++++ .../pokemon/view/pokemon_equipment_page.dart | 250 ++++++++ lib/features/pokemon/view/pokemon_page.dart | 227 +++++++ .../pokemon/view/pokemon_storage_page.dart | 149 +++++ lib/features/pokemon/view/shop_tab.dart | 305 ++++++++++ .../pokemon/widgets/pokemon_count_label.dart | 39 ++ .../widgets/pokemon_evolution_dialog.dart | 72 +++ .../pokemon/widgets/pokemon_grid_cell.dart | 51 ++ .../pokemon/widgets/pokemon_hp_bar.dart | 37 ++ .../pokemon/widgets/pokemon_search_field.dart | 58 ++ .../pokemon/widgets/shop_category_chips.dart | 38 ++ 21 files changed, 4030 insertions(+) create mode 100644 lib/features/pokemon/utils/action_feedback.dart create mode 100644 lib/features/pokemon/utils/item_merge.dart create mode 100644 lib/features/pokemon/utils/pokemon_dialogs.dart create mode 100644 lib/features/pokemon/utils/pokemon_style.dart create mode 100644 lib/features/pokemon/view/adventure_page.dart create mode 100644 lib/features/pokemon/view/battle_page.dart create mode 100644 lib/features/pokemon/view/battle_view.dart create mode 100644 lib/features/pokemon/view/inventory_tab.dart create mode 100644 lib/features/pokemon/view/my_pokemon_tab.dart create mode 100644 lib/features/pokemon/view/pokemon_center_tab.dart create mode 100644 lib/features/pokemon/view/pokemon_detail_page.dart create mode 100644 lib/features/pokemon/view/pokemon_equipment_page.dart create mode 100644 lib/features/pokemon/view/pokemon_page.dart create mode 100644 lib/features/pokemon/view/pokemon_storage_page.dart create mode 100644 lib/features/pokemon/view/shop_tab.dart create mode 100644 lib/features/pokemon/widgets/pokemon_count_label.dart create mode 100644 lib/features/pokemon/widgets/pokemon_evolution_dialog.dart create mode 100644 lib/features/pokemon/widgets/pokemon_grid_cell.dart create mode 100644 lib/features/pokemon/widgets/pokemon_hp_bar.dart create mode 100644 lib/features/pokemon/widgets/pokemon_search_field.dart create mode 100644 lib/features/pokemon/widgets/shop_category_chips.dart diff --git a/lib/features/pokemon/utils/action_feedback.dart b/lib/features/pokemon/utils/action_feedback.dart new file mode 100644 index 00000000..8f6666dd --- /dev/null +++ b/lib/features/pokemon/utils/action_feedback.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:tsdm_client/exceptions/exceptions.dart'; +import 'package:tsdm_client/features/pokemon/cubit/pokemon_cubit.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/utils/show_toast.dart'; + +/// Run a pokemon action and show the app's standard floating snack bar with its result. +/// +/// The action refreshes whatever it changed itself (the cubit reloads the affected slices in place), so this helper +/// only reports the outcome. +Future runPokemonAction(BuildContext context, Future Function() action) async { + final result = await action(); + if (!context.mounted) return; + final tr = context.t.pokemon; + final message = result.success + ? (result.message ?? tr.actionSuccess) + : pokemonMessageHint(context, result.message ?? context.t.general.networkError); + showSnackBar(context: context, message: message); +} + +/// Whether a pokemon api failure says the battling pet must be healed first. +/// +/// The plugin refuses to start a fight while the first pet is fainted or in an abnormal state, and points at the +/// pokemon center. The client can heal it itself (the heal is free), so this is the signal to do that and try again. +bool petNeedsHealingMessage(String? message) => + message != null && (message.contains('请先前往宠物中心治疗') || message.contains('已晕倒')); + +/// Whether a pokemon api failure says the battling pet must be healed first. +bool petNeedsHealingError(Object? error) => error is PokemonApiException && petNeedsHealingMessage(error.message); + +/// Whether a pokemon api failure says the battle is over. +/// +/// `heal_and_flee` answers `不在战斗中` when the fight is already gone, and a turn answers the English +/// `No active battle found` — the plugin has no battle to work on either way, so the client should stop fighting. +bool battleAlreadyOverMessage(String? message) => + message != null && (message.contains('不在战斗中') || message.contains('No active battle')); + +/// Whether [error] is the "battle was already over" failure. +bool battleAlreadyOverError(Object? error) => + error is PokemonApiException && battleAlreadyOverMessage(error.message); + +/// User-facing text for a pokemon api failure. +/// +/// The server's own message is shown verbatim for a business error; anything else (timeouts, handshake failures, +/// decode errors) becomes a generic network hint, so no raw exception text like `` reaches the user. +String pokemonErrorText(BuildContext context, Object error) => error is PokemonApiException + // The login guard's own code is more reliable than its text (the plugin does not send codes for its other errors). + ? (error.code == 401 ? context.t.pokemon.loginRequired : pokemonMessageHint(context, error.message ?? context.t.pokemon.actionFailed)) + : context.t.general.networkError; + +/// Localize the plugin's error texts, and rewrite the ones the player needs an explanation for. +/// +/// The plugin answers several skill errors and the box-capacity error in English, or without saying what to do, so map +/// them to plain localized text. Every other message passes through unchanged. +/// +/// These are text matches on purpose: the plugin sends no stable code for these failures (see the issue asking it to), +/// and the call sites that only have a message — a snack bar text, a `PokemonActionResult.message` — have nothing else to +/// go by. Callers holding the exception itself should look at `error.code` first, like [pokemonErrorText] does. +String pokemonMessageHint(BuildContext context, String message) { + final tr = context.t.pokemon; + // The plugin's API refuses a request without the session formhash; the client reads it again and retries, so this + // only shows when that did not help either. + if (message.contains('formhash')) return tr.formHashFailed; + // A session that ran out is answered by the login guard (401 or, on newer builds, a bodyless 403), and reads better + // as the login prompt than as its raw text. + if (message.contains('需要登录') || message.contains('请先登录')) return tr.loginRequired; + // The plugin answers an action its build does not implement with a plain English "Invalid action", so say what is + // really wrong instead of showing that. + if (message.contains('Invalid action')) return tr.actionUnsupported; + // The server answers this when it no longer has the battle (it ended on its own, or another client took it over), + // which is not a network problem: say what happened instead of showing the raw English. + if (message.contains('No active battle')) return tr.battleEnded; + if (message.contains('Skill slots are full')) return tr.skillSlotsFull; + if (message.contains('PP not full')) return tr.skillPpNotFull; + if (message.contains('during battle')) return tr.skillInBattle; + if (message.contains('Level requirement not met')) return tr.skillLevelTooLow; + if (message.contains('cannot be learned by this pokemon')) return tr.skillNotLearnable; + if (message.contains('already learned')) return tr.skillAlreadyLearned; + // The box capacity counts every owned pokemon (bag and storage), so moving pokemon into the storage never frees room. + if (message.contains('容量') || message.contains('数量已满') || message.contains('箱子')) return tr.boxFullHint; + return message; +} diff --git a/lib/features/pokemon/utils/item_merge.dart b/lib/features/pokemon/utils/item_merge.dart new file mode 100644 index 00000000..8f16be51 --- /dev/null +++ b/lib/features/pokemon/utils/item_merge.dart @@ -0,0 +1,23 @@ +import 'package:tsdm_client/features/pokemon/models/models.dart'; + +/// Merge inventory rows of the same item type into a single entry. +/// +/// The server returns one row per `pm_myitem` record, so owning the same item in two stacks would otherwise be shown +/// twice; merging sums the quantities. +List mergeInventoryItems(List items) { + final byType = {}; + final order = []; + for (final item in items) { + final existing = byType[item.typeId]; + if (existing == null) { + byType[item.typeId] = item; + order.add(item.typeId); + } else { + byType[item.typeId] = existing.copyWith( + quantity: existing.quantity + item.quantity, + nums: existing.nums + item.nums, + ); + } + } + return [for (final id in order) byType[id]!]; +} diff --git a/lib/features/pokemon/utils/pokemon_dialogs.dart b/lib/features/pokemon/utils/pokemon_dialogs.dart new file mode 100644 index 00000000..7890387b --- /dev/null +++ b/lib/features/pokemon/utils/pokemon_dialogs.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tsdm_client/features/root/view/root_page.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/routes/screen_paths.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; +import 'package:tsdm_client/widgets/custom_alert_dialog.dart'; + +/// Ask the user to confirm a pokemon action, using the app's standard dialog format. +/// +/// Wrapped in [RootPage] like every other dialog in the app, so the location stack and the teardown order stay +/// consistent. Returns true only when the user confirms; the buttons keep the pokemon wording. +Future showPokemonConfirmDialog({ + required BuildContext context, + required IconData icon, + required String title, + required String message, + required String confirmLabel, + bool dangerous = false, +}) async { + final result = await showDialog( + context: context, + builder: (context) => RootPage( + DialogPaths.pokemonConfirm, + CustomAlertDialog.sync( + title: AppDialogTitle(icon: icon, title: title, error: dangerous), + content: SelectableText(message), + actions: [ + TextButton(onPressed: () => context.pop(false), child: Text(context.t.general.cancel)), + FilledButton( + style: dangerous ? FilledButton.styleFrom(backgroundColor: Theme.of(context).colorScheme.error) : null, + onPressed: () => context.pop(true), + child: Text(confirmLabel), + ), + ], + ), + ), + ); + return result ?? false; +} + +/// Show a text-input dialog (rename / buy quantity) in the app's standard dialog format; null when cancelled. +Future showPokemonInputDialog({ + required BuildContext context, + required IconData icon, + required String title, + required String initialValue, + required String confirmLabel, + String? hintText, + int? maxLength, + TextInputType? keyboardType, +}) => showDialog( + context: context, + builder: (context) => RootPage( + DialogPaths.pokemonInput, + _PokemonInputDialog( + icon: icon, + title: title, + initialValue: initialValue, + confirmLabel: confirmLabel, + hintText: hintText, + maxLength: maxLength, + keyboardType: keyboardType, + ), + ), +); + +/// Body of [showPokemonInputDialog]; owns its [TextEditingController] so it lives exactly as long as the dialog. +class _PokemonInputDialog extends StatefulWidget { + const _PokemonInputDialog({ + required this.icon, + required this.title, + required this.initialValue, + required this.confirmLabel, + this.hintText, + this.maxLength, + this.keyboardType, + }); + + /// Icon shown in the dialog title. + final IconData icon; + + /// Dialog title. + final String title; + + /// Initial text of the field. + final String initialValue; + + /// Label of the confirm button. + final String confirmLabel; + + /// Field hint and label text. + final String? hintText; + + /// Maximum input length, when the value is limited. + final int? maxLength; + + /// Keyboard type for the field. + final TextInputType? keyboardType; + + @override + State<_PokemonInputDialog> createState() => _PokemonInputDialogState(); +} + +class _PokemonInputDialogState extends State<_PokemonInputDialog> { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialValue); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => CustomAlertDialog.sync( + title: AppDialogTitle(icon: widget.icon, title: widget.title), + content: TextField( + controller: _controller, + autofocus: true, + maxLength: widget.maxLength, + keyboardType: widget.keyboardType, + decoration: InputDecoration(hintText: widget.hintText, labelText: widget.hintText), + ), + actions: [ + TextButton(onPressed: () => context.pop(), child: Text(context.t.general.cancel)), + FilledButton(onPressed: () => context.pop(_controller.text.trim()), child: Text(widget.confirmLabel)), + ], + ); +} diff --git a/lib/features/pokemon/utils/pokemon_style.dart b/lib/features/pokemon/utils/pokemon_style.dart new file mode 100644 index 00000000..65f5984d --- /dev/null +++ b/lib/features/pokemon/utils/pokemon_style.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; + +/// Accent color of a pokemon move type (属性), used to tint skill labels. +Color pokemonTypeColor(String type) => switch (type) { + '普通' => const Color(0xFF9E9E9E), + '格斗' => const Color(0xFFC03028), + '飞行' => const Color(0xFF7986CB), + '毒' => const Color(0xFF9C27B0), + '地面' => const Color(0xFFBCAAA4), + '岩石' => const Color(0xFF8D6E63), + '虫' => const Color(0xFF8BC34A), + '幽灵' => const Color(0xFF673AB7), + '钢' => const Color(0xFF78909C), + '火' => const Color(0xFFE53935), + '水' => const Color(0xFF1E88E5), + '草' => const Color(0xFF43A047), + '电' => const Color(0xFFF9A825), + '超能' => const Color(0xFFD81B60), + '冰' => const Color(0xFF00ACC1), + '龙' => const Color(0xFF3949AB), + '恶' => const Color(0xFF546E7A), + '妖精' => const Color(0xFFEC407A), + _ => const Color(0xFF9E9E9E), +}; + +/// Emoji of a map area type (`pm_map.site`), mirroring the plugin web page. +String pokemonAreaEmoji(String areaType) => switch (areaType) { + 'l' => '🌿', + 'g' => '🌱', + 'p' => '💧', + 's' => '🌊', + 'b' => '🐚', + 'm' => '⛰️', + 'c' => '🕳️', + 'd' => '🏜️', + 'f' => '🏭', + 't' => '🏗️', + 'v' => '🏘️', + 'n' => '🏟️', + 'h' => '☁️', + 'o' => '🦑', + 'k' => '🌋', + _ => '❓', +}; + +/// Accent color of a map area type, matching the web page's badges. +Color pokemonAreaColor(String areaType) => switch (areaType) { + 'l' || 'g' => const Color(0xFF43A047), + 'p' || 's' || 'b' || 'o' => const Color(0xFF1E88E5), + 'm' || 'c' => const Color(0xFF8D6E63), + 'd' => const Color(0xFFC0A16B), + 'f' || 't' => const Color(0xFF78909C), + 'v' || 'n' => const Color(0xFF8E24AA), + 'h' => const Color(0xFF039BE5), + 'k' => const Color(0xFFE53935), + _ => const Color(0xFF9E9E9E), +}; + +/// Display name of a map region id (English id or legacy Chinese), mirroring the plugin. +String pokemonRegionName(String region) => switch (region) { + 'central' || '中央' => '中央地区', + 'north' || '北部' || '东北' => '北部地区', + 'east' || '东部' || '东南' => '东部地区', + 'south' || '南部' || '西南' => '南部地区', + 'west' || '西部' || '西北' => '西部地区', + 'ocean' || '海洋' => '海洋地区', + 'cave' || '洞窟' || '山洞' => '洞窟地区', + 'mountain' || '山脉' || '山谷' => '山脉地区', + 'sky' || '天空' => '天空地区', + 'desert' || '沙漠' => '沙漠地区', + 'city' || '城市' => '城市地区', + 'volcano' || '火山' => '火山地区', + 'wild' || '野外' => '野外地区', + 'special' || '特殊' => '特殊地区', + _ => region.isEmpty ? '未知地区' : region, +}; + +/// Sort order of a map region, mirroring the plugin. Accepts both the raw id and the display name. +int pokemonRegionOrder(String region) => + switch (region.endsWith('地区') ? region.substring(0, region.length - 2) : region) { + 'central' || '中央' => 1, + 'north' || '北部' || '东北' => 2, + 'east' || '东部' || '东南' => 3, + 'south' || '南部' || '西南' => 4, + 'west' || '西部' || '西北' => 5, + 'ocean' || '海洋' => 6, + 'cave' || '洞窟' || '山洞' => 7, + 'mountain' || '山脉' || '山谷' => 8, + 'sky' || '天空' => 9, + 'desert' || '沙漠' => 10, + 'city' || '城市' => 11, + 'volcano' || '火山' => 12, + 'wild' || '野外' => 13, + 'special' || '特殊' => 14, + _ => 99, +}; + +/// Difficulty of a map for a trainer with a given strongest pokemon level. +enum AdventureAdvice { + /// The map is at or below the trainer's level. + good, + + /// The map is a bit above the trainer's level. + risky, + + /// The map is well above the trainer's level. + danger, + + /// The trainer's level is so far above the map that the experience is barely worth the fight. + lowReward, + + /// The trainer has no pokemon. + unknown, +} + +/// Advice for a map given the level of the pokemon that would fight (null when there is none). +/// +/// Mirrors `get_recommendation` in the website's front-end (`rust/game/src/pages/adventure.rs`), including the "+10" +/// margin above the map's maximum level at which the fight stops being worth it. +AdventureAdvice adventureAdviceFor({required int minLevel, required int maxLevel, int? trainerLevel}) { + if (trainerLevel == null || trainerLevel <= 0) return AdventureAdvice.unknown; + if (trainerLevel < minLevel) return AdventureAdvice.danger; + if (trainerLevel > maxLevel + 10) return AdventureAdvice.lowReward; + if (trainerLevel <= maxLevel) return AdventureAdvice.good; + return AdventureAdvice.risky; +} + +/// Localized text of the state the server reports as [state]. +/// +/// The plugin answers a Chinese `state_text` (see `get_pokemon_state_text` in `plugin/api/pokemon.php`) and has no +/// stable code for the UI, so the client maps the numeric state it already mirrors for `Pokemon.needsHealing`. A state +/// a future plugin build adds still falls back to [serverText], which is better than showing nothing. +String pokemonStateText(BuildContext context, int state, String serverText) { + final tr = context.t.pokemon; + return switch (state) { + 1 => tr.stateNormal, + 2 || 3 || 4 => tr.stateSick, + 5 || 6 => tr.stateHungry, + 7 => tr.stateTired, + 8 || 9 || 10 => tr.stateExcited, + 11 => tr.stateHurt, + 12 || 13 || 14 => tr.stateHappy, + 15 => tr.statePanicked, + 16 || 17 => tr.stateVain, + 18 || 19 => tr.stateAngry, + 20 || 21 || 22 => tr.stateWeak, + _ => serverText.isEmpty ? tr.stateCritical : serverText, + }; +} diff --git a/lib/features/pokemon/view/adventure_page.dart b/lib/features/pokemon/view/adventure_page.dart new file mode 100644 index 00000000..b794399a --- /dev/null +++ b/lib/features/pokemon/view/adventure_page.dart @@ -0,0 +1,554 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:fpdart/fpdart.dart' show Left, Right; +import 'package:go_router/go_router.dart'; +import 'package:tsdm_client/exceptions/exceptions.dart'; +import 'package:tsdm_client/features/pokemon/models/models.dart'; +import 'package:tsdm_client/features/pokemon/repository/adventure_cache.dart'; +import 'package:tsdm_client/features/pokemon/utils/action_feedback.dart'; +import 'package:tsdm_client/features/pokemon/utils/pokemon_image.dart'; +import 'package:tsdm_client/features/pokemon/utils/pokemon_style.dart'; +import 'package:tsdm_client/features/pokemon/view/battle_page.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/instance.dart'; +import 'package:tsdm_client/routes/screen_paths.dart'; +import 'package:tsdm_client/utils/retry_button.dart'; +import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/cached_image/cached_image.dart'; +import 'package:tsdm_client/widgets/indicator.dart'; + +/// The adventure (冒险) page: world map (regions) -> region maps -> map detail -> battle. +class AdventurePage extends StatefulWidget { + /// Constructor. + const AdventurePage({super.key}); + + @override + State createState() => _AdventurePageState(); +} + +class _AdventurePageState extends State { + late final AdventureCache _cache; + bool _loading = true; + bool _failed = false; + bool _needLogin = false; + String? _failureMessage; + + /// Selected region id; null shows the world map (region list). + String? _region; + + /// Classic mode: show every map as one flat list instead of world map -> region -> map. + bool _classic = false; + + /// Whether a map detail sheet / battle start is already in flight, so a double tap cannot start two battles. + bool _sheetOpen = false; + + @override + void initState() { + super.initState(); + _cache = getIt.get(); + _loading = !_cache.hasData; + unawaited(_restoreMode()); + unawaited(_load()); + } + + /// Restore the remembered world-map / classic-list choice. + Future _restoreMode() async { + final classic = await _cache.isClassicMode(); + if (mounted && classic != _classic) setState(() => _classic = classic); + } + + /// Switch the map list mode and remember it for the next visit. + Future _setClassicMode(bool classic) async { + setState(() => _classic = classic); + await _cache.setClassicMode(classic: classic); + } + + /// Resume an unfinished battle and refresh maps/pokemon concurrently. + Future _load() async { + // A battle cannot appear between two entries a moment apart, so a fresh "no battle" answer is reused. + final askedForBattle = !_cache.battleCheckFresh; + final recoverFuture = askedForBattle ? _cache.recoverBattle().run() : null; + final mapsFuture = _cache.refreshMaps(); + // Rebuild once the pokemon list arrives so the challenge advice is up to date. + unawaited( + _cache.refreshPokemons().then((_) { + if (mounted) setState(() {}); + }), + ); + final recover = recoverFuture == null ? null : await recoverFuture; + final maps = await mapsFuture; + if (!mounted) return; + // Trust the answer for a few seconds, but only when it was actually asked for. + if (askedForBattle) { + if (recover case Right(value: null)) _cache.markNoBattle(); + } + + if (recover case Left(:final value) when value is PokemonApiException && value.code == 401) { + setState(() { + _needLogin = true; + _loading = false; + }); + return; + } + BattleScene? active; + if (recover case Right(:final value)) { + active = value; + } + + // Only an actually running battle is resumed: a finished scene (like the defeat the player just confirmed) would + // otherwise pop the battle page right back open as soon as the adventure page reloads. + if (active != null && active.isActive) { + if (_cache.isBattleFinished(active)) { + // A battle the player already finished but the server still keeps: end it in the background and stay here. + unawaited(_cache.flee(active.battleId).run()); + } else { + setState(() => _loading = false); + await context.pushNamed(ScreenPaths.pokemonBattle, extra: BattlePageArgs.resume(active, mapId: active.mapId)); + if (mounted) await _load(); + return; + } + } else { + // Nothing is running, so no finished battle can still be resumed. + _cache.clearFinishedBattles(); + } + + maps.fold( + (e) => setState(() { + _loading = false; + // Keep showing the cached list when a background refresh fails. + if (_cache.hasData) { + _failed = false; + return; + } + _failed = true; + _failureMessage = e.message; + }), + (_) => setState(() { + _loading = false; + _failed = false; + }), + ); + } + + /// Start a battle on [mapId] and return the ready scene (null + a snack bar on failure). + /// + /// The party is healed first when a pet needs it, the way the website sets off (`get_injured_pokemons()` then + /// `heal_pokemon` in `rust/game/src/pages/adventure.rs`), so a fight starts from a full party. A refusal that still + /// asks for healing is answered by healing again and retrying, so a fainted pet does not keep the player from setting + /// off either. + Future _startBattle(int mapId, {int? bossTypeId}) async { + // A battle is about to run: the remembered "no battle" answer no longer holds. + _cache.invalidateBattleCheck(); + await _cache.healParty(); + if (!mounted) return null; + var result = await _cache.startBattle(mapId, bossTypeId: bossTypeId).run(); + if (result.isLeft() && petNeedsHealingError(result.fold((e) => e, (_) => null))) { + await _cache.healParty(); + if (!mounted) return null; + result = await _cache.startBattle(mapId, bossTypeId: bossTypeId).run(); + } + return result.fold( + (e) { + showSnackBar(context: context, message: pokemonErrorText(context, e)); + return null; + }, + (scene) => scene, + ); + } + + /// Open the map detail sheet; on "start" push the battle page with the ready scene (no loading screen). + /// + /// The guard only covers the sheet. It used to stay set while the battle page was open and while the map list was + /// reloaded afterwards, which silently swallowed every tap for seconds after a fight. + Future _openMap(AdventureMap map) async { + if (_sheetOpen) return; + (BattleScene, int?)? started; + _sheetOpen = true; + try { + started = await showModalBottomSheet<(BattleScene, int?)>( + context: context, + isScrollControlled: true, + builder: (context) => _MapDetailSheet( + map: map, + trainerLevel: _cache.activePokemonLevel, + onStart: ({bossTypeId}) => _startBattle(map.id, bossTypeId: bossTypeId), + ), + ); + } finally { + _sheetOpen = false; + } + if (started == null || !mounted) return; + await context.pushNamed( + ScreenPaths.pokemonBattle, + extra: BattlePageArgs.resume(started.$1, mapId: map.id, bossTypeId: started.$2), + ); + // Refresh in the background: waiting for it here kept the page busy for another round trip or two. + if (mounted) unawaited(_load()); + } + + @override + Widget build(BuildContext context) { + final tr = context.t.adventure; + final maps = _cache.maps; + final Widget body; + if (_needLogin) { + body = Center(child: Text(tr.loginRequired)); + } else if (maps == null) { + body = _failed + ? buildRetryButton(context, () => unawaited(_load()), message: _failureMessage ?? tr.failedToLoad) + : const CenteredCircularIndicator(); + } else if (maps.isEmpty) { + body = RefreshIndicator( + onRefresh: _load, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [Padding(padding: const EdgeInsets.all(24), child: Center(child: Text(tr.empty)))], + ), + ); + } else { + body = RefreshIndicator( + onRefresh: _load, + child: _classic + ? _buildClassicMaps(context, maps) + : _region == null + ? _buildWorldMap(context, maps) + : _buildRegion(context, maps), + ); + } + final showBack = !_classic && _region != null; + return PopScope( + canPop: !showBack, + onPopInvokedWithResult: (didPop, result) { + if (!didPop && showBack) setState(() => _region = null); + }, + child: Scaffold( + appBar: AppBar( + title: Text(showBack ? pokemonRegionName(_region!) : tr.title), + leading: showBack + ? IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => setState(() => _region = null)) + : null, + actions: [ + IconButton( + icon: Icon(_classic ? Icons.map_outlined : Icons.view_list_outlined), + tooltip: _classic ? tr.regionMode : tr.classicMode, + onPressed: () => unawaited(_setClassicMode(!_classic)), + ), + IconButton( + icon: const Icon(Icons.refresh), + tooltip: tr.refresh, + onPressed: _loading ? null : () => unawaited(_load()), + ), + ], + ), + body: SafeArea(child: body), + ), + ); + } + + /// World map: one card per region, with how many adventure spots it holds. + Widget _buildWorldMap(BuildContext context, List maps) { + final tr = context.t.adventure; + final counts = {}; + for (final map in maps) { + // Group by the display name so region synonyms (e.g. central/中央) share one card. + final id = pokemonRegionName(map.region); + counts[id] = (counts[id] ?? 0) + 1; + } + final regions = counts.keys.toList()..sort((a, b) => pokemonRegionOrder(a).compareTo(pokemonRegionOrder(b))); + return ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(8), + itemCount: regions.length, + itemBuilder: (context, index) { + final id = regions[index]; + return Card( + child: ListTile( + leading: const Icon(Icons.public), + title: Text(pokemonRegionName(id)), + subtitle: Text(tr.mapCount(count: counts[id] ?? 0)), + trailing: const Icon(Icons.chevron_right), + onTap: () => setState(() => _region = id), + ), + ); + }, + ); + } + + /// Region maps: the adventure spots of the selected region. + Widget _buildRegion(BuildContext context, List maps) { + final tr = context.t.adventure; + final regionMaps = maps.where((m) => pokemonRegionName(m.region) == _region).toList(); + return ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(8), + itemCount: regionMaps.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + return Padding( + padding: const EdgeInsets.fromLTRB(4, 4, 4, 8), + child: Text( + '${pokemonRegionName(_region ?? '')} · ${tr.mapCount(count: regionMaps.length)}', + style: Theme.of(context).textTheme.titleSmall, + ), + ); + } + final map = regionMaps[index - 1]; + return _MapCard(map: map, trainerLevel: _cache.activePokemonLevel, onTap: () => unawaited(_openMap(map))); + }, + ); + } + + /// Classic mode: every map as one flat list; tapping one still opens the detail sheet. + Widget _buildClassicMaps(BuildContext context, List maps) => ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(8), + itemCount: maps.length, + itemBuilder: (context, index) => + _MapCard(map: maps[index], trainerLevel: _cache.activePokemonLevel, onTap: () => unawaited(_openMap(maps[index]))), + ); +} + +/// One map card in the region list. +class _MapCard extends StatelessWidget { + const _MapCard({required this.map, required this.trainerLevel, required this.onTap}); + + /// The map this card shows. + final AdventureMap map; + + /// Strongest pokemon level of the trainer, for the challenge advice; null when unknown. + final int? trainerLevel; + + /// Called when the card is tapped. + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final tr = context.t.adventure; + final advice = adventureAdviceFor(minLevel: map.minLevel, maxLevel: map.maxLevel, trainerLevel: trainerLevel); + final (adviceText, adviceColor) = _adviceText(context, advice, trainerLevel); + final areaColor = pokemonAreaColor(map.areaType); + return Card( + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(pokemonAreaEmoji(map.areaType), style: const TextStyle(fontSize: 20)), + const SizedBox(width: 8), + Expanded( + child: Text( + map.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + if (map.areaTypeName.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: areaColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: areaColor.withValues(alpha: 0.5)), + ), + child: Text(map.areaTypeName, style: TextStyle(fontSize: 11, color: areaColor)), + ), + ], + ), + if (map.mode != 'boss') ...[ + const SizedBox(height: 6), + Text(adviceText, style: TextStyle(color: adviceColor, fontSize: 12)), + ], + const SizedBox(height: 2), + Text( + map.mode == 'boss' + ? '${tr.bossChallenge} · ${map.bosses?.length ?? 0}' + : tr.levelRange(min: map.minLevel, max: map.maxLevel), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ), + ); + } +} + +/// Advice text + color for a difficulty. +(String, Color) _adviceText(BuildContext context, AdventureAdvice advice, int? trainerLevel) { + final tr = context.t.adventure; + final level = trainerLevel ?? 0; + return switch (advice) { + AdventureAdvice.good => (tr.adviceGood(level: level), Colors.green), + AdventureAdvice.risky => (tr.adviceRisky(level: level), Colors.orange), + AdventureAdvice.danger => (tr.adviceDanger(level: level), Colors.red), + AdventureAdvice.lowReward => (tr.adviceLowReward, Colors.blueGrey), + AdventureAdvice.unknown => (tr.adviceUnknown, Colors.grey), + }; +} + +/// Bottom sheet showing one map's advice, basic info, wild pokemon and bosses, plus start/back. +class _MapDetailSheet extends StatefulWidget { + const _MapDetailSheet({required this.map, required this.trainerLevel, required this.onStart}); + + /// The map whose details are shown. + final AdventureMap map; + + /// Strongest pokemon level of the trainer, for the challenge advice; null when unknown. + final int? trainerLevel; + + /// Starts a battle (optionally a boss one) and returns the ready scene, or null on failure. + final Future Function({int? bossTypeId}) onStart; + + @override + State<_MapDetailSheet> createState() => _MapDetailSheetState(); +} + +class _MapDetailSheetState extends State<_MapDetailSheet> { + bool _busy = false; + + Future _start({int? bossTypeId}) async { + if (_busy) return; + setState(() => _busy = true); + final scene = await widget.onStart(bossTypeId: bossTypeId); + if (!mounted) return; + if (scene != null) { + Navigator.of(context).pop((scene, bossTypeId)); + } else { + setState(() => _busy = false); + } + } + + Widget _section(String title, Widget child) => Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 6), + child, + ], + ), + ); + + @override + Widget build(BuildContext context) { + final tr = context.t.adventure; + final map = widget.map; + final advice = adventureAdviceFor( + minLevel: map.minLevel, + maxLevel: map.maxLevel, + trainerLevel: widget.trainerLevel, + ); + final (adviceText, adviceColor) = _adviceText(context, advice, widget.trainerLevel); + final hasWild = map.mode != 'boss'; + final modeText = switch (map.mode) { + 'boss' => tr.modeBoss, + 'hybrid' => tr.modeHybrid, + _ => tr.modeWild, + }; + final bosses = map.bosses ?? const []; + return SafeArea( + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.85), + child: ListView( + shrinkWrap: true, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Row( + children: [ + Text(pokemonAreaEmoji(map.areaType), style: const TextStyle(fontSize: 24)), + const SizedBox(width: 8), + Expanded(child: Text(map.name, style: Theme.of(context).textTheme.titleLarge)), + ], + ), + ), + if (hasWild) _section(tr.challengeAdvice, Text(adviceText, style: TextStyle(color: adviceColor))), + _section( + tr.basicInfo, + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (hasWild) Text(tr.levelRange(min: map.minLevel, max: map.maxLevel)), + Text('${tr.terrainType}: ${map.areaTypeName}'), + Text('${tr.mapMode}: $modeText'), + ], + ), + ), + if (hasWild && map.wildPokemons.isNotEmpty) + _section( + tr.possiblePokemon, + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final p in map.wildPokemons) + Chip(visualDensity: VisualDensity.compact, label: Text(p.name)), + ], + ), + ), + if (bosses.isNotEmpty) + _section( + tr.bossChallenge, + Column( + children: [ + for (final boss in bosses) + ListTile( + contentPadding: EdgeInsets.zero, + enabled: !_busy, + leading: SizedBox( + width: 40, + height: 40, + child: CachedImage( + pokemonImageUrl(boss.pokemonTypeId), + width: 40, + height: 40, + fit: BoxFit.contain, + ), + ), + title: Text(boss.pokemonName), + subtitle: Text('${tr.level(level: boss.level)} · ×${boss.bossMultiplier}'), + trailing: const Icon(Icons.chevron_right), + onTap: () => unawaited(_start(bossTypeId: boss.pokemonTypeId)), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), + child: Column( + children: [ + if (hasWild) + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _busy ? null : () => unawaited(_start()), + child: _busy + ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) + : Text(tr.startAdventure), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: TextButton( + onPressed: _busy ? null : () => Navigator.of(context).pop(), + child: Text(tr.back), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/pokemon/view/battle_page.dart b/lib/features/pokemon/view/battle_page.dart new file mode 100644 index 00000000..0dc28f58 --- /dev/null +++ b/lib/features/pokemon/view/battle_page.dart @@ -0,0 +1,512 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tsdm_client/features/pokemon/cubit/battle_cubit.dart'; +import 'package:tsdm_client/features/pokemon/models/models.dart'; +import 'package:tsdm_client/features/pokemon/repository/adventure_cache.dart'; +import 'package:tsdm_client/features/pokemon/repository/skill_order_store.dart'; +import 'package:tsdm_client/features/pokemon/utils/action_feedback.dart'; +import 'package:tsdm_client/features/pokemon/utils/pokemon_dialogs.dart'; +import 'package:tsdm_client/features/pokemon/utils/pokemon_image.dart'; +import 'package:tsdm_client/features/pokemon/utils/pokemon_style.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/instance.dart'; +import 'package:tsdm_client/routes/screen_paths.dart'; +import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; +import 'package:tsdm_client/widgets/cached_image/cached_image.dart'; +import 'package:tsdm_client/widgets/custom_alert_dialog.dart'; +import 'package:tsdm_client/widgets/indicator.dart'; + +part 'battle_view.dart'; + +/// Arguments passed to [BattlePage] through the route `extra`. +class BattlePageArgs { + /// Start a new battle. + const BattlePageArgs.start({required this.mapId, this.bossTypeId}) : scene = null; + + /// Resume a battle already built elsewhere (recover, or started from the map detail). + /// + /// [mapId]/[bossTypeId] are remembered so the result dialog's "fight again" can restart the same fight. + const BattlePageArgs.resume(this.scene, {this.mapId, this.bossTypeId}); + + /// Map id for a new battle. + final int? mapId; + + /// Boss species id for a new battle, when chosen. + final int? bossTypeId; + + /// Scene to resume. + final BattleScene? scene; +} + +/// The battle page: turn-based fight against a wild pokemon or boss. +class BattlePage extends StatefulWidget { + /// Constructor. + const BattlePage({required this.args, super.key}); + + /// Battle arguments. + final BattlePageArgs args; + + @override + State createState() => _BattlePageState(); +} + +class _BattlePageState extends State with WidgetsBindingObserver { + late final BattleCubit _cubit; + + /// Guards the end-of-battle result dialog against being stacked twice. + bool _resultShowing = false; + + /// Whether leaving the page already asked for the party heal, so the back handler does not ask a second time. + bool _healingOnLeave = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _cubit = BattleCubit(); + final args = widget.args; + if (args.scene != null) { + _cubit.resume(args.scene!); + } else { + unawaited(_cubit.start(args.mapId!, bossTypeId: args.bossTypeId)); + } + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + unawaited(_cubit.close()); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + if (state == AppLifecycleState.resumed) unawaited(_handleResumed()); + } + + /// Handle the app coming back to the foreground. + /// + /// A request the OS parked while the app was away can keep the buttons disabled for as long as the platform client + /// allows, so the cubit drops that wait and reads the battle from the server again. + Future _handleResumed() async { + final result = await _cubit.onAppResumed(); + if (!mounted) return; + switch (result) { + case BattleResumeResult.nothing: + return; + case BattleResumeResult.refreshed: + showSnackBar(context: context, message: context.t.battle.resynced); + case BattleResumeResult.gone: + showSnackBar(context: context, message: context.t.battle.endedWhileAway); + context.pop(); + case BattleResumeResult.failed: + _showError(context.t.general.networkError); + } + } + + @override + Widget build(BuildContext context) => BlocProvider.value( + value: _cubit, + child: BlocListener( + listenWhen: (previous, current) => + current.scene != null && !current.scene!.isActive && (previous.scene?.isActive ?? false), + listener: (context, state) => unawaited(_showResult(state.scene!)), + child: BlocBuilder( + builder: (context, state) { + final scene = state.scene; + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + if (didPop) { + return; + } + unawaited(_handleBack()); + }, + child: Scaffold( + appBar: AppBar( + title: Text( + scene == null + ? context.t.battle.title + : '${scene.mapName} · ${context.t.battle.turn(turn: state.turn)}', + ), + ), + body: switch (state.status) { + // Keep the current battle on screen while the next one starts (no full-page spinner flash). + BattleStatus.loading when scene != null => + _buildBattleView( + scene, + busy: true, + wildGender: state.wildGender, + wildShiny: state.wildShiny, + skillOrder: state.skillOrder, + turn: state.turn, + ), + BattleStatus.loading => const CenteredCircularIndicator(), + BattleStatus.needLogin => _NeedLogin(onRetry: () => unawaited(_cubit.start(widget.args.mapId!, bossTypeId: widget.args.bossTypeId))), + BattleStatus.failure => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text(state.failureMessage ?? context.t.battle.failedToLoad, textAlign: TextAlign.center), + ), + const SizedBox(height: 8), + if (widget.args.mapId != null) + // A pet that needs healing cannot be sent out, and the client already healed and retried once: + // point at the centre instead of offering a retry that would fail the same way. + if (petNeedsHealingMessage(state.failureMessage)) + FilledButton( + onPressed: () => unawaited(context.pushNamed(ScreenPaths.pokemon, extra: 1)), + child: Text(context.t.battle.goToCenter), + ) + // The server no longer has this battle (it ended on its own, or another client took it over): + // asking again would fail the same way, so go back to the map instead. + else if (battleAlreadyOverMessage(state.failureMessage)) + FilledButton( + onPressed: _leaveHealingParty, + child: Text(context.t.battle.backToMap), + ) + else + TextButton( + onPressed: () => unawaited( + _cubit.start(widget.args.mapId!, bossTypeId: widget.args.bossTypeId), + ), + child: Text(context.t.general.retry), + ), + ], + ), + ), + BattleStatus.success when scene == null => const CenteredCircularIndicator(), + BattleStatus.success => + _buildBattleView( + scene!, + busy: state.actionInProgress, + wildGender: state.wildGender, + wildShiny: state.wildShiny, + skillOrder: state.skillOrder, + turn: state.turn, + ), + }, + ), + ); + }, + ), + ), + ); + + Widget _buildBattleView( + BattleScene scene, { + required bool busy, + int? wildGender, + bool? wildShiny, + List skillOrder = const [], + int turn = 1, + }) => _BattleView( + scene: scene, + busy: busy, + wildGender: wildGender, + wildShiny: wildShiny, + skillOrder: skillOrder, + turn: turn, + onSkill: (id) => unawaited(_useSkill(id)), + onItem: () => unawaited(_useItem()), + onCapture: () => unawaited(_capture()), + onSwitch: () => unawaited(_switch()), + onFlee: () => unawaited(_flee()), + onHealAndFlee: () => unawaited(_healAndFlee()), + ); + + Future _useSkill(int skillId) async { + final result = await _cubit.useSkill(skillId); + if (!mounted) return; + if (!result.success) _showError(result.message); + } + + Future _flee() async { + final result = await _cubit.flee(); + if (!mounted) return; + if (!result.success) { + _showError(result.message); + return; + } + // The plugin rolls for the escape and the wild pokemon hits back when it fails, so a flee that left the battle + // running has to say so instead of looking like the tap was ignored. + final scene = _cubit.state.scene; + if (scene != null && scene.isActive) _showSnack(scene.message ?? context.t.battle.fleeFailed); + } + + /// Leave the battle and heal the battling pokemon at the center, then close the battle page. + Future _healAndFlee() async { + final result = await _cubit.healAndFlee(); + if (!mounted) return; + if (!result.success) { + _showError(result.message); + return; + } + _showSnack(result.message ?? context.t.pokemon.actionSuccess); + context.pop(); + } + + void _showSnack(String message) => showSnackBar(context: context, message: message); + + Future _switch() async { + final scene = _cubit.state.scene; + if (scene == null) return; + final pokemons = await _cubit.loadPokemons(); + if (!mounted) return; + final currentId = scene.myPokemon.instanceId; + final usable = pokemons.where((p) => p.hp > 0 && p.state != 0 && p.isCarried && p.id != currentId).toList(); + if (usable.isEmpty) { + _showError(context.t.battle.noSwitchable); + return; + } + final picked = await showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile(title: Text(context.t.battle.switchPokemon)), + for (final p in usable) + ListTile( + leading: SizedBox( + width: 40, + height: 40, + child: CachedImage(pokemonImageUrl(p.typeId), width: 40, height: 40, fit: BoxFit.contain), + ), + title: Text(p.displayName), + subtitle: Text('${context.t.pokemon.level(level: p.level)} · ${context.t.pokemon.hp} ${p.hp}/${p.maxHp}'), + onTap: () => Navigator.of(context).pop(p), + ), + ], + ), + ), + ); + if (picked == null || !mounted) return; + final result = await _cubit.switchPokemon(pokemonId: picked.id); + if (!mounted) return; + if (!result.success) _showError(result.message); + } + + /// Handle the back navigation: ask whether to flee when the battle is still active. + /// Leave the battle page, asking for the party heal first. + /// + /// The plugin never heals a pokemon on its own, so every way out of this page goes through here: the result dialog's + /// "ok" and the back button used to differ, which left a party that was hurt in the fight unhealed on one of them. The + /// pop does not wait for the answer (the heal is best effort and takes a round trip). + void _leaveHealingParty() { + if (!mounted) return; + if (!_healingOnLeave) { + _healingOnLeave = true; + unawaited(_cubit.healParty()); + } + context.pop(); + } + + Future _handleBack() async { + final scene = _cubit.state.scene; + if (scene == null || !scene.isActive) { + // The fight is over (or never started): leaving is the same as the result dialog's "ok". + _leaveHealingParty(); + return; + } + final tr = context.t.battle; + final leave = await showPokemonConfirmDialog( + context: context, + icon: Icons.directions_run, + title: tr.flee, + message: tr.confirmFlee, + confirmLabel: tr.flee, + dangerous: true, + ); + if (!leave || !mounted) return; + final result = await _cubit.flee(); + if (!mounted) return; + if (!result.success) { + _showError(result.message); + return; + } + final current = _cubit.state.scene; + if (current != null && current.isActive) { + // The escape failed (and the wild pokemon answered), so stay here: leaving would let the battle run on unseen. + // The result dialog takes over instead once a flee does succeed. + _showSnack(current.message ?? context.t.battle.fleeFailed); + return; + } + } + + Future _capture() async { + final balls = await _cubit.loadBalls(); + if (!mounted) return; + if (balls.isEmpty) { + _showError(context.t.battle.noBalls); + return; + } + final ball = await showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile(title: Text(context.t.battle.capture)), + for (final item in balls) + ListTile( + leading: SizedBox( + width: 40, + height: 40, + child: item.image.isEmpty + ? const Icon(Icons.catching_pokemon) + : CachedImage(pokemonItemImageUrl(item.image), width: 40, height: 40, fit: BoxFit.contain), + ), + title: Text(item.name), + subtitle: Text('×${item.quantity}'), + onTap: () => Navigator.of(context).pop(item), + ), + ], + ), + ), + ); + if (ball == null || !mounted) return; + final result = await _cubit.capture(ball.typeId); + if (!mounted) return; + if (!result.success) _showError(result.message); + } + + Future _useItem() async { + final items = await _cubit.loadBattleItems(); + if (!mounted) return; + if (items.isEmpty) { + _showError(context.t.battle.noItems); + return; + } + final item = await showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile(title: Text(context.t.battle.items)), + for (final i in items) + ListTile( + leading: SizedBox( + width: 40, + height: 40, + child: CachedImage(pokemonItemImageUrl(i.img), width: 40, height: 40, fit: BoxFit.contain), + ), + title: Text(i.name), + subtitle: Text('×${i.nums}'), + onTap: () => Navigator.of(context).pop(i), + ), + ], + ), + ), + ); + if (item == null || !mounted) return; + final result = await _cubit.useItem(item.id); + if (!mounted) return; + if (result.requiresSkillSelection) { + final skill = await _pickSkill(result.availableSkills ?? const []); + if (skill == null || !mounted) return; + final r2 = await _cubit.useItemOnSkill(item.id, skill.id); + if (!mounted) return; + if (!r2.success) _showError(r2.message); + } else if (result.message != null) { + _showError(result.message); + } + } + + Future _pickSkill(List skills) async { + if (skills.isEmpty) return null; + // Same device-local order as the skill buttons. + final ordered = SkillOrderStore.apply(skills, _cubit.state.skillOrder, (s) => s.id); + return showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile(title: Text(context.t.battle.useOnSkill)), + for (final skill in ordered) + ListTile( + title: Text(skill.name ?? ''), + subtitle: Text('PP ${skill.pp}/${skill.maxPp}'), + onTap: () => Navigator.of(context).pop(skill), + ), + ], + ), + ), + ); + } + + void _showError(String? message) { + showSnackBar( + context: context, + message: pokemonMessageHint(context, message ?? context.t.general.networkError), + ); + } + + Future _showResult(BattleScene scene) async { + if (_resultShowing) return; + _resultShowing = true; + try { + final tr = context.t.battle; + final title = switch (scene.status) { + 'victory' => tr.victory, + 'captured' => tr.captured, + 'fled' => tr.fled, + 'defeat' => tr.defeat, + _ => scene.status, + }; + final lines = [ + if ((scene.message ?? '').isNotEmpty) scene.message!, + if (scene.rewards != null) tr.rewards(exp: scene.rewards!.exp, money: scene.rewards!.money), + if (scene.levelUp != null && scene.levelUp!.levelUp) tr.levelUp(level: scene.levelUp!.newLevel), + ]; + // The forum stores its own copy of the party, so push the badge when the battle changed it. + if ((scene.levelUp?.levelUp ?? false) || scene.status == 'captured') { + unawaited(_cubit.refreshBadge()); + } + final mapId = widget.args.mapId; + final again = await showDialog( + context: context, + builder: (context) => CustomAlertDialog.sync( + title: AppDialogTitle(icon: Icons.emoji_events_outlined, title: title), + content: Text(lines.join('\n')), + actions: [ + TextButton(onPressed: () => context.pop(false), child: Text(tr.ok)), + // A successful escape only needs an acknowledgement; there is nothing there to fight again. + if (mapId != null && scene.status != 'fled') + FilledButton(onPressed: () => context.pop(true), child: Text(tr.again)), + ], + ), + ); + if (!mounted) return; + // A defeat does not always end the server-side battle (the plugin keeps it while a usable backup pokemon exists), + // which would make the adventure page resume the finished battle. End it here instead of leaving the player to + // flee by hand; the battle is remembered so the adventure page never resumes it even if ending it failed. + if (scene.status == 'defeat') { + getIt.get().markBattleFinished(scene); + final result = await _cubit.finishDefeat(scene); + if (!mounted) return; + if (!result.success) _showError(result.message); + } + if (mapId != null && again == true) { + // Healing happens inside, after the page is already marked busy, so the tap is acknowledged immediately; only + // the pet that just fought is healed (the scene knows its HP and PP). + await _cubit.fightAgain(mapId, bossTypeId: widget.args.bossTypeId); + return; + } + // Leaving the fight heals the party too, but the pop must not wait for it: the battle can leave a pokemon hurt, + // out of skill PP or in an abnormal state and the server heal is free, so run it in the background. + _leaveHealingParty(); + } finally { + _resultShowing = false; + } + } +} diff --git a/lib/features/pokemon/view/battle_view.dart b/lib/features/pokemon/view/battle_view.dart new file mode 100644 index 00000000..f28c43cd --- /dev/null +++ b/lib/features/pokemon/view/battle_view.dart @@ -0,0 +1,375 @@ +part of 'battle_page.dart'; + +class _NeedLogin extends StatelessWidget { + const _NeedLogin({required this.onRetry}); + + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center(child: TextButton(onPressed: onRetry, child: Text(context.t.battle.loginRequired))); + } +} + +class _BattleView extends StatelessWidget { + const _BattleView({ + required this.scene, + required this.busy, + required this.onSkill, + required this.onItem, + required this.onCapture, + required this.onSwitch, + required this.onFlee, + required this.onHealAndFlee, + this.wildGender, + this.wildShiny, + this.skillOrder = const [], + this.turn = 1, + }); + + final BattleScene scene; + final bool busy; + + /// Gender of the wild pokemon from the last running turn; the finished scene reports the reset value. + final int? wildGender; + + /// Shiny state of the wild pokemon from the last running turn. + final bool? wildShiny; + + /// Device-local display order of the player's skills (skill ids, empty before it is loaded). + final List skillOrder; + + /// Round the battle is in, counted by the client: the plugin has no turn counter. + final int turn; + final ValueChanged onSkill; + final VoidCallback onItem; + final VoidCallback onCapture; + final VoidCallback onSwitch; + final VoidCallback onFlee; + final VoidCallback onHealAndFlee; + + /// Height of the battle log box: proportional to the screen and scaled with the font, but fixed so a longer message + /// cannot resize the box and move the fighters and the action area around. + double _logHeight(BuildContext context, BoxConstraints constraints) => + MediaQuery.textScalerOf(context).scale(1) * (constraints.maxHeight * 0.12).clamp(56.0, 96.0); + + @override + Widget build(BuildContext context) { + final tr = context.t.battle; + // The plugin returns skills in slot order; apply the device-local order the player set on the detail page. + final skills = SkillOrderStore.apply( + scene.myPokemon.skills.where((s) => s.id != 0).toList(), + skillOrder, + (s) => s.id, + ); + return LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + // The fighters and the log are inside the page scroll view, so insetting this padding keeps them clear + // of a landscape cutout (the front camera) without another SafeArea layer. + padding: EdgeInsets.fromLTRB( + 12 + MediaQuery.paddingOf(context).left, + 8, + 12 + MediaQuery.paddingOf(context).right, + 8, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // The two pokemon sit diagonally, like the games: the wild one at the top-left and mine at the + // bottom-right, with the battle log between them. Stacking them vertically (instead of one row) + // also keeps the layout usable on small screens. + Align( + alignment: Alignment.centerLeft, + child: _Fighter( + image: pokemonBattleBackImageUrl(scene.wildPokemon.id), + name: scene.wildPokemon.name ?? '', + level: scene.wildPokemon.level, + hp: scene.wildPokemon.hp, + maxHp: scene.wildPokemon.maxHp, + boss: scene.wildPokemon.isBoss ?? false, + gender: wildGender ?? scene.wildPokemon.gender, + shiny: wildShiny ?? scene.wildPokemon.isShiny, + spriteSize: _battleSpriteSize(constraints), + ), + ), + const SizedBox(height: 8), + // Battle log between the two pokemon. The box keeps a fixed height so a longer message cannot + // resize it and move the fighters and the action area around while the player watches the fight. + SizedBox( + height: _logHeight(context, constraints), + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + (scene.message ?? '').isEmpty ? tr.turn(turn: turn) : scene.message!, + maxLines: 3, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: _Fighter( + image: pokemonImageUrl(scene.myPokemon.id), + name: scene.myPokemon.name ?? '', + level: scene.myPokemon.level, + hp: scene.myPokemon.hp, + maxHp: scene.myPokemon.maxHp, + boss: false, + spriteSize: _battleSpriteSize(constraints), + ), + ), + ], + ), + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Keep the action area the same height whether or not a request is running: an inserted bar would + // push the buttons down (and past the bottom of the view on a short screen) while it is shown. + SizedBox( + height: 2, + child: busy ? const LinearProgressIndicator(minHeight: 2) : null, + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: FilledButton(onPressed: busy ? null : () => onSkill(0), child: Text(tr.normalAttack)), + ), + if (skills.isNotEmpty) ...[ + const SizedBox(height: 8), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisExtent: 56, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + ), + itemCount: skills.length, + itemBuilder: (context, index) { + final skill = skills[index]; + return FilledButton.tonal( + onPressed: busy || !skill.usable ? null : () => onSkill(skill.id), + child: Text.rich( + TextSpan( + children: [ + TextSpan(text: skill.name ?? ''), + if (skill.skillType.isNotEmpty) ...[ + const TextSpan(text: '\n'), + TextSpan( + text: skill.skillType, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: pokemonTypeColor(skill.skillType), + fontWeight: FontWeight.bold, + ), + ), + ], + TextSpan( + text: + '${skill.skillType.isEmpty ? '\n' : ' '}${skill.category} · ' + 'PP ${skill.pp}/${skill.maxPp} · ' + '${context.t.pokemon.skillPower}: ${skill.power}', + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ); + }, + ), + ], + const SizedBox(height: 8), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisExtent: 48, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + ), + itemCount: 4, + itemBuilder: (context, index) => switch (index) { + 0 => FilledButton.tonalIcon( + onPressed: busy ? null : onItem, + icon: const Icon(Icons.backpack_outlined), + label: Text(tr.items), + ), + 1 => FilledButton.tonalIcon( + onPressed: busy ? null : onCapture, + icon: const Icon(Icons.catching_pokemon), + label: Text(tr.capture), + ), + 2 => FilledButton.tonalIcon( + onPressed: busy ? null : onSwitch, + icon: const Icon(Icons.swap_horiz), + label: Text(tr.switchPokemon), + ), + // The last cell holds two smaller buttons so heal-and-flee and flee stay on one row while the + // pair still fills the cell like every other button. + _ => Row( + children: [ + Expanded( + child: FilledButton.tonalIcon( + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 4), + visualDensity: VisualDensity.compact, + ), + onPressed: busy ? null : onHealAndFlee, + icon: const Icon(Icons.medical_services_outlined, size: 18), + label: Text( + context.t.pokemon.healAndFlee, + style: Theme.of(context).textTheme.labelSmall, + ), + ), + ), + const SizedBox(width: 4), + Expanded( + child: FilledButton.tonalIcon( + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 4), + visualDensity: VisualDensity.compact, + ), + onPressed: busy ? null : onFlee, + icon: const Icon(Icons.directions_run, size: 18), + label: Text(tr.flee, style: Theme.of(context).textTheme.labelSmall), + ), + ), + ], + ), + }, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _Fighter extends StatelessWidget { + const _Fighter({ + required this.image, + required this.name, + required this.level, + required this.hp, + required this.maxHp, + required this.boss, + this.gender, + this.shiny = false, + this.spriteSize = 64, + }); + + final String image; + final String name; + final int level; + final int hp; + final int maxHp; + final bool boss; + + /// Gender (0=雄性, 1=雌性); null hides the symbol (the player's own pokemon). + final int? gender; + + /// Whether the pokemon is shiny. + final bool shiny; + + /// Sprite edge length; scales with the screen. + final double spriteSize; + + @override + Widget build(BuildContext context) { + final tr = context.t.pokemon; + final ratio = maxHp <= 0 ? 0.0 : (hp / maxHp).clamp(0.0, 1.0); + final color = ratio > 0.5 ? Colors.green : ratio > 0.2 ? Colors.orange : Colors.red; + return ConstrainedBox( + constraints: BoxConstraints(maxWidth: spriteSize * 2.6), + child: Column( + children: [ + if (boss) Chip(visualDensity: VisualDensity.compact, label: Text(context.t.battle.boss)), + CachedImage(image, width: spriteSize, height: spriteSize, fit: BoxFit.contain), + Text.rich( + TextSpan( + children: [ + TextSpan(text: name.isEmpty ? tr.level(level: level) : '$name · ${tr.level(level: level)}'), + if (shiny) + const WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: EdgeInsets.only(left: 4), + child: Icon(Icons.auto_awesome, size: 14, color: Colors.amber), + ), + ), + if (gender == 0 || gender == 1) + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsets.only(left: 2), + child: Text( + gender == 0 ? '♂' : '♀', + style: TextStyle(color: gender == 0 ? Colors.blue : Colors.pink, fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: ratio, + minHeight: 8, + color: color, + backgroundColor: color.withValues(alpha: 0.2), + ), + ), + ), + const SizedBox(width: 8), + Text('${tr.hp} $hp/$maxHp', style: Theme.of(context).textTheme.bodySmall), + ], + ), + ], + ), + ); + } +} + +/// Sprite size for the battle fighters: scales with the screen so they fit at the top corners on any phone. +/// +/// The width share is generous enough to keep the fighters filling a tall screen (a small sprite left a wide empty gap +/// between the battle area and the action buttons), while the height share still wins in landscape. +double _battleSpriteSize(BoxConstraints constraints) { + final byWidth = constraints.maxWidth / 4; + final byHeight = constraints.maxHeight / 6; + return (byWidth < byHeight ? byWidth : byHeight).clamp(48.0, 132.0); +} diff --git a/lib/features/pokemon/view/inventory_tab.dart b/lib/features/pokemon/view/inventory_tab.dart new file mode 100644 index 00000000..e1da414d --- /dev/null +++ b/lib/features/pokemon/view/inventory_tab.dart @@ -0,0 +1,216 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:tsdm_client/features/pokemon/cubit/pokemon_cubit.dart'; +import 'package:tsdm_client/features/pokemon/models/models.dart'; +import 'package:tsdm_client/features/pokemon/utils/action_feedback.dart'; +import 'package:tsdm_client/features/pokemon/utils/item_merge.dart'; +import 'package:tsdm_client/features/pokemon/utils/pokemon_image.dart'; +import 'package:tsdm_client/features/pokemon/widgets/pokemon_grid_cell.dart'; +import 'package:tsdm_client/features/pokemon/widgets/pokemon_search_field.dart'; +import 'package:tsdm_client/features/pokemon/widgets/shop_category_chips.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/widgets/cached_image/cached_image.dart'; + +/// "Inventory" tab: browse items by category, search, and use them on pokemon. +class InventoryTab extends StatefulWidget { + /// Constructor. + const InventoryTab({super.key}); + + @override + State createState() => _InventoryTabState(); +} + +class _InventoryTabState extends State { + final ScrollController _scrollController = ScrollController(); + String _query = ''; + bool _grid = false; + bool _loadingMore = false; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + } + + @override + void dispose() { + _scrollController + ..removeListener(_onScroll) + ..dispose(); + super.dispose(); + } + + /// Load the next page when the list nears its end. The threshold matches the shop tab. + Future _onScroll() async { + if (_loadingMore || !_scrollController.hasClients) return; + final position = _scrollController.position; + if (position.pixels < position.maxScrollExtent - 200) return; + setState(() => _loadingMore = true); + try { + await context.read().loadMoreInventory(); + } finally { + if (mounted) setState(() => _loadingMore = false); + } + } + + /// Search only filters what is loaded, so pull the remaining pages first. + void _onSearch(String value) { + setState(() => _query = value); + if (_query.isEmpty) return; + unawaited(context.read().loadAllInventory()); + } + + @override + Widget build(BuildContext context) { + final state = context.watch().state; + final cubit = context.read(); + final tr = context.t.pokemon; + final categories = <(ShopCategory, String)>[ + (ShopCategory.all, tr.shopCategoryAll), + (ShopCategory.med, tr.shopCategoryMed), + (ShopCategory.ball, tr.shopCategoryBall), + (ShopCategory.stone, tr.shopCategoryStone), + (ShopCategory.boost, tr.shopCategoryBoost), + (ShopCategory.equipment, tr.shopCategoryEquipment), + ]; + final all = mergeInventoryItems(state.inventory?.items ?? const []); + final items = _query.isEmpty + ? all + : all.where((e) => e.name.contains(_query) || e.description.contains(_query)).toList(); + return Column( + children: [ + PokemonCategoryChips( + categories: categories, + selected: state.inventoryCategory, + onSelected: (category) => unawaited(cubit.setInventoryCategory(category)), + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 8, 4), + child: Row( + children: [ + Expanded(child: PokemonSearchField(hintText: tr.searchItems, onChanged: _onSearch)), + IconButton( + tooltip: _grid ? tr.viewList : tr.viewGrid, + icon: Icon(_grid ? Icons.view_list_outlined : Icons.grid_view_outlined), + onPressed: () => setState(() => _grid = !_grid), + ), + ], + ), + ), + Expanded( + child: RefreshIndicator( + onRefresh: cubit.refreshAll, + child: items.isEmpty + ? ListView( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), + children: [Padding(padding: const EdgeInsets.all(24), child: Center(child: Text(tr.empty)))], + ) + : _grid + ? GridView.builder( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(8), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisExtent: 104, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + ), + itemCount: items.length + 1, + itemBuilder: (context, index) => index >= items.length + ? _footer() + : PokemonGridCell( + image: items[index].image.isEmpty ? null : pokemonItemImageUrl(items[index].image), + fallbackIcon: Icons.inventory_2_outlined, + title: items[index].name, + subtitle: '×${items[index].quantity}', + onTap: items[index].canUse ? () => unawaited(_use(items[index])) : null, + ), + ) + : ListView.builder( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(8), + itemCount: items.length + 1, + itemBuilder: (context, index) { + if (index >= items.length) return _footer(); + final item = items[index]; + return Card( + child: ListTile( + leading: SizedBox( + width: 40, + height: 40, + child: item.image.isEmpty + ? const Icon(Icons.inventory_2_outlined) + : CachedImage( + pokemonItemImageUrl(item.image), + width: 40, + height: 40, + fit: BoxFit.contain, + ), + ), + title: Text(item.name), + subtitle: Text( + item.description.isEmpty ? '×${item.quantity}' : '${item.description}\n×${item.quantity}', + ), + trailing: item.canUse + ? FilledButton.tonal(onPressed: () => _use(item), child: Text(tr.use)) + : null, + ), + ); + }, + ), + ), + ), + ], + ); + } + + /// Trailing loading cell shown while the next page is fetched. + Widget _footer() => Padding( + padding: const EdgeInsets.all(12), + child: Center( + child: _loadingMore + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + : const SizedBox.shrink(), + ), + ); + + /// Use [item]; a pokemon-targeted item first asks which pokemon. + Future _use(InventoryItem item) async { + final cubit = context.read(); + final pokemons = cubit.state.pokemons ?? const []; + if (pokemons.isEmpty) { + await runPokemonAction(context, () => cubit.useItem(item.typeId)); + return; + } + if (!mounted) return; + final pokemon = await showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile(title: Text(context.t.pokemon.useOn)), + for (final p in pokemons) + ListTile( + leading: SizedBox( + width: 40, + height: 40, + child: CachedImage(pokemonImageUrl(p.typeId), width: 40, height: 40, fit: BoxFit.contain), + ), + title: Text(p.displayName), + subtitle: Text('${context.t.pokemon.hp} ${p.hp}/${p.maxHp}'), + onTap: () => Navigator.of(context).pop(p), + ), + ], + ), + ), + ); + if (pokemon == null || !mounted) return; + await runPokemonAction(context, () => cubit.useItem(item.typeId, pokemonId: pokemon.id)); + } +} diff --git a/lib/features/pokemon/view/my_pokemon_tab.dart b/lib/features/pokemon/view/my_pokemon_tab.dart new file mode 100644 index 00000000..26be0eac --- /dev/null +++ b/lib/features/pokemon/view/my_pokemon_tab.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tsdm_client/features/pokemon/cubit/pokemon_cubit.dart'; +import 'package:tsdm_client/features/pokemon/models/models.dart'; +import 'package:tsdm_client/features/pokemon/utils/action_feedback.dart'; +import 'package:tsdm_client/features/pokemon/utils/pokemon_dialogs.dart'; +import 'package:tsdm_client/features/pokemon/utils/pokemon_image.dart'; +import 'package:tsdm_client/features/pokemon/utils/pokemon_style.dart'; +import 'package:tsdm_client/features/pokemon/widgets/pokemon_count_label.dart'; +import 'package:tsdm_client/features/pokemon/widgets/pokemon_hp_bar.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/routes/screen_paths.dart'; +import 'package:tsdm_client/widgets/cached_image/cached_image.dart'; + +/// "My pokemon" tab: list of owned pokemon with management actions. +class MyPokemonTab extends StatelessWidget { + /// Constructor. + const MyPokemonTab({super.key}); + + @override + Widget build(BuildContext context) { + final state = context.watch().state; + final tr = context.t.pokemon; + final all = state.pokemons ?? const []; + final pokemons = all.where((p) => p.isCarried).toList(); + final (:carried, :boxed) = carriedAndBoxed(all); + return RefreshIndicator( + onRefresh: () => context.read().refreshAll(), + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(8), + children: [ + PokemonCountLabel(carried: carried, boxed: boxed), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.archive_outlined), + title: Text(tr.storage), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + await context.pushNamed(ScreenPaths.pokemonStorage); + if (context.mounted) await context.read().refreshPokemons(); + }, + ), + const Divider(height: 1), + if (pokemons.isEmpty) + Padding(padding: const EdgeInsets.all(24), child: Center(child: Text(tr.empty))) + else + for (final pokemon in pokemons) _PokemonTile(pokemon: pokemon), + ], + ), + ); + } +} + +class _PokemonTile extends StatelessWidget { + const _PokemonTile({required this.pokemon}); + + final Pokemon pokemon; + + @override + Widget build(BuildContext context) { + final tr = context.t.pokemon; + final isFirst = pokemon.isActive; + return Card( + child: ListTile( + onTap: () async { + await context.pushNamed(ScreenPaths.pokemonDetail, extra: pokemon); + if (context.mounted) await context.read().refreshPokemons(); + }, + leading: SizedBox( + width: 48, + height: 48, + child: CachedImage(pokemonImageUrl(pokemon.typeId), width: 48, height: 48, fit: BoxFit.contain), + ), + title: Row( + children: [ + Flexible(child: Text(pokemon.displayName, overflow: TextOverflow.ellipsis)), + if (pokemon.isShiny) const Icon(Icons.auto_awesome, size: 16, color: Colors.amber), + if (isFirst) ...[ + const SizedBox(width: 4), + Chip( + visualDensity: VisualDensity.compact, + label: Text(tr.first), + labelStyle: const TextStyle(fontSize: 11), + ), + ], + ], + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('${tr.level(level: pokemon.level)} · ${tr.state}: ${pokemonStateText(context, pokemon.state, pokemon.stateText)}'), + const SizedBox(height: 2), + PokemonHpBar(hp: pokemon.hp, maxHp: pokemon.maxHp), + ], + ), + trailing: PopupMenuButton<_PokemonAction>( + onSelected: (action) => _onAction(context, action), + itemBuilder: (context) => [ + PopupMenuItem(value: _PokemonAction.setFirst, child: Text(tr.setFirst)), + PopupMenuItem(value: _PokemonAction.rename, child: Text(tr.rename)), + PopupMenuItem(value: _PokemonAction.toStorage, child: Text(tr.toStorage)), + PopupMenuItem(value: _PokemonAction.release, child: Text(tr.release)), + ], + ), + ), + ); + } + + Future _onAction(BuildContext context, _PokemonAction action) async { + final cubit = context.read(); + switch (action) { + case _PokemonAction.setFirst: + await runPokemonAction(context, () => cubit.setFirst(pokemon.id)); + case _PokemonAction.rename: + await _rename(context, cubit); + case _PokemonAction.toStorage: + await runPokemonAction(context, () => cubit.movePokemon(pokemon.id, 3)); + case _PokemonAction.release: + await _release(context, cubit); + } + } + + Future _rename(BuildContext context, PokemonCubit cubit) async { + final tr = context.t.pokemon; + final name = await showPokemonInputDialog( + context: context, + icon: Icons.drive_file_rename_outline, + title: tr.rename, + initialValue: pokemon.displayName, + confirmLabel: tr.rename, + hintText: tr.rename, + maxLength: 20, + ); + if (name == null || name.isEmpty || !context.mounted) return; + await runPokemonAction(context, () => cubit.rename(pokemon.id, name)); + } + + Future _release(BuildContext context, PokemonCubit cubit) async { + final tr = context.t.pokemon; + final confirmed = await showPokemonConfirmDialog( + context: context, + icon: Icons.delete_outline, + title: tr.release, + message: tr.releaseConfirm(name: pokemon.displayName), + confirmLabel: tr.release, + dangerous: true, + ); + if (!confirmed || !context.mounted) return; + await runPokemonAction(context, () => cubit.release(pokemon.id)); + } +} + +enum _PokemonAction { setFirst, rename, toStorage, release } diff --git a/lib/features/pokemon/view/pokemon_center_tab.dart b/lib/features/pokemon/view/pokemon_center_tab.dart new file mode 100644 index 00000000..2306d3d9 --- /dev/null +++ b/lib/features/pokemon/view/pokemon_center_tab.dart @@ -0,0 +1,127 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:tsdm_client/features/pokemon/cubit/pokemon_cubit.dart'; +import 'package:tsdm_client/features/pokemon/models/models.dart'; +import 'package:tsdm_client/features/pokemon/utils/pokemon_image.dart'; +import 'package:tsdm_client/features/pokemon/utils/pokemon_style.dart'; +import 'package:tsdm_client/features/pokemon/widgets/pokemon_count_label.dart'; +import 'package:tsdm_client/features/pokemon/widgets/pokemon_hp_bar.dart'; +import 'package:tsdm_client/features/pokemon/widgets/pokemon_search_field.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/cached_image/cached_image.dart'; + +/// "Pokemon center" tab: heal pokemon (with a name search), carried and boxed kept apart. +class PokemonCenterTab extends StatefulWidget { + /// Constructor. + const PokemonCenterTab({super.key}); + + @override + State createState() => _PokemonCenterTabState(); +} + +class _PokemonCenterTabState extends State { + String _query = ''; + + /// A pokemon matches when its displayed name (possibly a nickname) or its species name contains the query. + bool _matches(Pokemon p) => _query.isEmpty || p.displayName.contains(_query) || p.name.contains(_query); + + @override + Widget build(BuildContext context) { + final state = context.watch().state; + final tr = context.t.pokemon; + final all = state.pokemons ?? const []; + final (:carried, :boxed) = carriedAndBoxed(all); + final matched = all.where(_matches).toList(); + final carriedList = matched.where((p) => p.isCarried).toList(); + final boxedList = matched.where((p) => p.isStored).toList(); + return Column( + children: [ + PokemonCountLabel(carried: carried, boxed: boxed), + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 0), + child: PokemonSearchField(hintText: tr.searchPokemon, onChanged: (value) => setState(() => _query = value)), + ), + Expanded( + child: RefreshIndicator( + onRefresh: () => context.read().refreshAll(), + child: matched.isEmpty + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [Padding(padding: const EdgeInsets.all(24), child: Center(child: Text(tr.empty)))], + ) + : ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(8), + children: [ + // Carried pokemon first, then the ones sitting in the storage box. + if (carriedList.isNotEmpty) _sectionHeader(context, tr.carried, carriedList.length), + for (final p in carriedList) _tile(context, p), + if (boxedList.isNotEmpty) _sectionHeader(context, tr.storage, boxedList.length), + for (final p in boxedList) _tile(context, p), + ], + ), + ), + ), + ], + ); + } + + /// A small header separating the carried pokemon from the boxed ones. + Widget _sectionHeader(BuildContext context, String title, int count) => Padding( + padding: const EdgeInsets.fromLTRB(4, 8, 4, 4), + child: Text('$title · $count', style: Theme.of(context).textTheme.titleSmall), + ); + + /// One pokemon row with the heal and heal-and-flee actions. + Widget _tile(BuildContext context, Pokemon pokemon) { + final tr = context.t.pokemon; + return Card( + child: ListTile( + leading: SizedBox( + width: 48, + height: 48, + child: CachedImage(pokemonImageUrl(pokemon.typeId), width: 48, height: 48, fit: BoxFit.contain), + ), + title: Text(pokemon.displayName), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('${tr.level(level: pokemon.level)} · ${tr.state}: ${pokemonStateText(context, pokemon.state, pokemon.stateText)}'), + const SizedBox(height: 2), + PokemonHpBar(hp: pokemon.hp, maxHp: pokemon.maxHp), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.medical_services_outlined), + tooltip: tr.heal, + onPressed: () => unawaited(_heal(context, pokemon, flee: false)), + ), + IconButton( + icon: const Icon(Icons.airline_stops), + tooltip: tr.healAndFlee, + onPressed: () => unawaited(_heal(context, pokemon, flee: true)), + ), + ], + ), + ), + ); + } + + /// Heal [pokemon] at the center, reporting the result with the app's standard floating snack bar. + Future _heal(BuildContext context, Pokemon pokemon, {required bool flee}) async { + final tr = context.t.pokemon; + final cubit = context.read(); + final result = await (flee ? cubit.healAndFlee(pokemon.id) : cubit.heal(pokemon.id)); + if (!context.mounted) return; + final message = result.success + ? (result.message ?? tr.actionSuccess) + : (result.message ?? context.t.general.networkError); + showSnackBar(context: context, message: message); + } +} diff --git a/lib/features/pokemon/view/pokemon_detail_page.dart b/lib/features/pokemon/view/pokemon_detail_page.dart new file mode 100644 index 00000000..d95a9657 --- /dev/null +++ b/lib/features/pokemon/view/pokemon_detail_page.dart @@ -0,0 +1,475 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tsdm_client/features/pokemon/models/models.dart'; +import 'package:tsdm_client/features/pokemon/repository/pokemon_repository.dart'; +import 'package:tsdm_client/features/pokemon/repository/skill_order_store.dart'; +import 'package:tsdm_client/features/pokemon/utils/action_feedback.dart'; +import 'package:tsdm_client/features/pokemon/utils/pokemon_dialogs.dart'; +import 'package:tsdm_client/features/pokemon/utils/pokemon_image.dart'; +import 'package:tsdm_client/features/pokemon/utils/pokemon_style.dart'; +import 'package:tsdm_client/features/pokemon/widgets/pokemon_evolution_dialog.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/routes/screen_paths.dart'; +import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/cached_image/cached_image.dart'; +import 'package:tsdm_client/widgets/indicator.dart'; + +/// Detail page of a single pokemon: abilities, skills and quick actions. +class PokemonDetailPage extends StatefulWidget { + /// Constructor. + const PokemonDetailPage({required this.pokemon, super.key}); + + /// Basic pokemon info from the list; the full detail (with stats) is fetched here. + final Pokemon pokemon; + + @override + State createState() => _PokemonDetailPageState(); +} + +class _PokemonDetailPageState extends State { + final PokemonRepository _repository = PokemonRepository(); + final SkillOrderStore _skillOrder = SkillOrderStore(); + Pokemon? _detail; + bool _loading = true; + bool _failed = false; + bool _actionInProgress = false; + + /// Locally maintained skills after a learn/forget; the production detail endpoint returns empty skill slots. + List? _skillsOverride; + + /// Skill ids in the order the player dragged them into (device-only, the plugin has no reorder API). + List _savedOrder = const []; + + /// Effective skills: the detail's when it has any, otherwise the list item's (the `list` endpoint fills them). + List get _skills { + final override = _skillsOverride; + if (override != null) return override; + final fromDetail = _detail?.skills.where((s) => !s.isEmpty).toList() ?? const []; + final base = fromDetail.isNotEmpty ? fromDetail : widget.pokemon.skills.where((s) => !s.isEmpty).toList(); + return SkillOrderStore.apply(base, _savedOrder, (s) => s.typeId); + } + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + /// Load the detail; when [silent] the page keeps showing the current data instead of flashing a spinner. + Future _load({bool silent = false}) async { + if (!silent) { + setState(() { + _loading = true; + _failed = false; + }); + } + final orderFuture = _skillOrder.load(widget.pokemon.id); + final result = await _repository.getPokemonDetail(widget.pokemon.id).run(); + final order = await orderFuture; + if (!mounted) return; + _savedOrder = order; + result.fold( + (_) => setState(() { + if (silent && _detail != null) return; + _loading = false; + _failed = true; + }), + (detail) => setState(() { + _detail = detail; + _loading = false; + _failed = false; + if (!silent) _skillsOverride = null; + }), + ); + } + + Future _setFirst() async { + if (_actionInProgress) return; + setState(() => _actionInProgress = true); + final tr = context.t.pokemon; + try { + final result = await _repository.setFirst(widget.pokemon.id).run(); + if (!mounted) return; + final success = result.isRight(); + _showSnack(success ? tr.actionSuccess : tr.actionFailed); + if (success) context.pop(true); + } finally { + if (mounted) setState(() => _actionInProgress = false); + } + } + + Future _forget(PokemonSkill skill) async { + if (_actionInProgress) return; + final tr = context.t.pokemon; + final pokemon = _detail ?? widget.pokemon; + final confirmed = await showPokemonConfirmDialog( + context: context, + icon: Icons.remove_circle_outline, + title: tr.forgetSkill, + message: tr.forgetConfirm(name: pokemon.displayName, skill: skill.name), + confirmLabel: tr.forgetSkill, + ); + if (!confirmed || !mounted) return; + setState(() => _actionInProgress = true); + try { + final result = await _repository.forgetSkill(pokemon.id, skill.typeId).run(); + if (!mounted) return; + result.fold((e) => _showSnack(pokemonErrorText(context, e)), (_) => _showSnack(tr.skillForgotten)); + if (result.isRight()) { + setState(() => _skillsOverride = _skills.where((s) => s.typeId != skill.typeId).toList()); + } + } finally { + if (mounted) setState(() => _actionInProgress = false); + } + } + + Future _learn() async { + if (_actionInProgress) return; + final pokemon = _detail ?? widget.pokemon; + setState(() => _actionInProgress = true); + final result = await _repository.getLearnableSkills(pokemon.id).run(); + if (!mounted) return; + setState(() => _actionInProgress = false); + result.fold( + (e) => _showSnack(pokemonErrorText(context, e)), + (skills) => unawaited(_pickLearnable(pokemon, skills)), + ); + } + + Future _pickLearnable(Pokemon pokemon, LearnableSkills learnable) async { + final tr = context.t.pokemon; + if (learnable.availableSkills.isEmpty && learnable.unlockedSkills.isEmpty) { + _showSnack(tr.noLearnableSkills); + return; + } + final picked = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => SafeArea( + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.7), + child: ListView( + shrinkWrap: true, + children: [ + ListTile(title: Text(tr.learnSkill)), + if (learnable.availableSkills.isNotEmpty) ...[ + _sheetHeader(context, tr.availableSkills), + for (final skill in learnable.availableSkills) + ListTile( + leading: const Icon(Icons.add_circle_outline), + title: Text(skill.name), + subtitle: Text( + '${skill.type} · ${skill.category} · ${tr.skillPower}: ${skill.power} · ' + 'PP ${skill.maxPp}', + ), + onTap: () => Navigator.of(context).pop(skill), + ), + ], + if (learnable.unlockedSkills.isNotEmpty) ...[ + _sheetHeader(context, tr.unlockedSkills), + for (final skill in learnable.unlockedSkills) + ListTile( + enabled: false, + leading: const Icon(Icons.lock_outline), + title: Text(skill.name), + subtitle: Text( + '${tr.skillRequiredLevel(level: skill.requiredLevel)} · ${skill.type} · ' + '${tr.skillPower}: ${skill.power}', + ), + ), + ], + ], + ), + ), + ), + ); + if (picked == null || !mounted) return; + await _learnSelected(pokemon, picked); + } + + Widget _sheetHeader(BuildContext context, String title) => Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Text(title, style: Theme.of(context).textTheme.labelLarge), + ); + + /// Move a skill in the list (drag to reorder) and remember the new order on this device. + void _reorderSkills(int oldIndex, int newIndex) { + final skills = [..._skills]; + // ReorderableListView reports the target index before the dragged item is removed. + final target = newIndex > oldIndex ? newIndex - 1 : newIndex; + final moved = skills.removeAt(oldIndex); + skills.insert(target, moved); + setState(() => _skillsOverride = skills); + unawaited(_skillOrder.save(widget.pokemon.id, skills.map((s) => s.typeId).toList())); + } + + Future _learnSelected(Pokemon pokemon, LearnableSkill skill) async { + // Four slots is the plugin's limit: with all of them used, ask which one to replace first. + if (_skills.length >= 4) { + await _replaceSkill(pokemon, skill); + return; + } + await _learnSkill(pokemon, skill); + } + + /// Ask which of the pokemon's skills to forget, then learn [skill] in its place. + Future _replaceSkill(Pokemon pokemon, LearnableSkill skill) async { + final tr = context.t.pokemon; + final current = _skills; + final picked = await showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: ListView( + shrinkWrap: true, + children: [ + ListTile(title: Text(tr.replaceSkillTitle), subtitle: Text(skill.name)), + const Divider(height: 1), + for (final s in current) + ListTile( + // The plugin only forgets a skill whose PP is full. + enabled: s.pp >= s.maxPp, + leading: Icon(s.pp >= s.maxPp ? Icons.remove_circle_outline : Icons.lock_outline), + title: Text(s.name), + subtitle: Text('PP ${s.pp}/${s.maxPp}${s.pp >= s.maxPp ? '' : ' · ${tr.skillPpNotFull}'}'), + onTap: s.pp >= s.maxPp ? () => Navigator.of(context).pop(s) : null, + ), + ], + ), + ), + ); + if (picked == null || !mounted) return; + await _forgetThenLearn(pokemon, picked, skill); + } + + /// Forget [oldSkill], then learn [newSkill]; both steps update the local skill list optimistically. + Future _forgetThenLearn(Pokemon pokemon, PokemonSkill oldSkill, LearnableSkill newSkill) async { + final tr = context.t.pokemon; + setState(() => _actionInProgress = true); + try { + final forgotten = await _repository.forgetSkill(pokemon.id, oldSkill.typeId).run(); + if (!mounted) return; + final forgetError = forgotten.fold((e) => e, (_) => null); + if (forgetError != null) { + _showSnack(pokemonErrorText(context, forgetError)); + return; + } + setState(() => _skillsOverride = _skills.where((s) => s.typeId != oldSkill.typeId).toList()); + final learned = await _repository.learnSkill(pokemon.id, newSkill.id).run(); + if (!mounted) return; + learned.fold((e) => _showSnack(pokemonErrorText(context, e)), (_) => _showSnack(tr.skillLearned)); + if (learned.isRight()) setState(() => _skillsOverride = [..._skills, _localSkill(newSkill)]); + } finally { + if (mounted) setState(() => _actionInProgress = false); + } + } + + /// Learn [skill] when a free slot is available. + Future _learnSkill(Pokemon pokemon, LearnableSkill skill) async { + final tr = context.t.pokemon; + setState(() => _actionInProgress = true); + try { + final result = await _repository.learnSkill(pokemon.id, skill.id).run(); + if (!mounted) return; + result.fold((e) => _showSnack(pokemonErrorText(context, e)), (_) => _showSnack(tr.skillLearned)); + // The production detail endpoint returns empty skill slots, so show the learnt skill at once. + if (result.isRight()) setState(() => _skillsOverride = [..._skills, _localSkill(skill)]); + } finally { + if (mounted) setState(() => _actionInProgress = false); + } + } + + /// The local skill entry for a freshly learned [skill]. + PokemonSkill _localSkill(LearnableSkill skill) => PokemonSkill( + typeId: skill.id, + pp: skill.maxPp, + name: skill.name, + skillType: skill.type, + category: skill.category, + level: skill.requiredLevel, + power: skill.power, + maxPp: skill.maxPp, + ); + + /// Open the equipment page, then reload the detail because equipping changes the stats. + Future _openEquipment(Pokemon pokemon) async { + await context.pushNamed(ScreenPaths.pokemonEquipment, extra: pokemon); + if (mounted) await _load(silent: true); + } + + void _showSnack(String? message) { + showSnackBar(context: context, message: message ?? context.t.pokemon.actionFailed); + } + + @override + Widget build(BuildContext context) { + final tr = context.t.pokemon; + final pokemon = _detail ?? widget.pokemon; + final stats = pokemon.stats; + final skills = _skills; + // The production detail endpoint omits `site`; fall back to the list item so the first pet is not offered + // "set first". + final isActive = (_detail?.site ?? 0) == 0 ? widget.pokemon.isActive : _detail!.isActive; + return Scaffold( + appBar: AppBar(title: Text(pokemon.displayName)), + // A landscape cutout (the front camera) sits on the left or right edge, so inset every side, not just the bottom + // the way a navigation bar alone would need. The app bar insets itself already. + body: SafeArea( + child: _loading + ? const CenteredCircularIndicator() + : _failed + ? Center(child: TextButton(onPressed: _load, child: Text(context.t.general.retry))) + : ListView( + padding: const EdgeInsets.all(16), + children: [ + Center( + child: CachedImage(pokemonImageUrl(pokemon.typeId), width: 128, height: 128, fit: BoxFit.contain), + ), + const SizedBox(height: 8), + Center( + child: Wrap( + spacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text(tr.level(level: pokemon.level), style: Theme.of(context).textTheme.titleMedium), + if (pokemon.isShiny) Chip(visualDensity: VisualDensity.compact, label: Text(tr.shiny)), + if (pokemon.quality != null) + Chip(visualDensity: VisualDensity.compact, label: Text('${tr.quality}: ${pokemon.quality}')), + ], + ), + ), + const SizedBox(height: 8), + Text( + '${tr.exp}: ${pokemon.exp} / ${pokemon.expForNextLevel} · ' + '${tr.expToNext(exp: pokemon.expToNextLevel)}', + ), + const SizedBox(height: 4), + LinearProgressIndicator(value: pokemon.expProgress), + const SizedBox(height: 8), + Text('${tr.state}: ${pokemonStateText(context, pokemon.state, pokemon.stateText)} · ${tr.affection}: ${pokemon.affection}'), + Text('${tr.hp} ${pokemon.hp}/${pokemon.maxHp}'), + if (stats != null) ...[ + const SizedBox(height: 16), + Text(tr.stats, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _StatChip(label: tr.hp, value: stats.hp), + _StatChip(label: tr.attack, value: stats.attack), + _StatChip(label: tr.defense, value: stats.defense), + _StatChip(label: tr.spAttack, value: stats.spAttack), + _StatChip(label: tr.spDefense, value: stats.spDefense), + _StatChip(label: tr.speed, value: stats.speed), + ], + ), + ], + const SizedBox(height: 16), + Row( + children: [ + Expanded(child: Text(tr.skills, style: Theme.of(context).textTheme.titleMedium)), + TextButton.icon( + onPressed: _actionInProgress ? null : _learn, + icon: const Icon(Icons.add), + label: Text(tr.learnSkill), + ), + ], + ), + if (skills.isEmpty) + ListTile(dense: true, contentPadding: EdgeInsets.zero, title: Text(tr.noSkills)) + else + // Drag to reorder: the plugin has no API for it, so the order is only remembered on this device. + ReorderableListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + buildDefaultDragHandles: false, + onReorder: _reorderSkills, + children: [ + for (var i = 0; i < skills.length; i++) + ListTile( + key: ValueKey(skills[i].typeId), + dense: true, + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.bolt_outlined), + title: Text(skills[i].name), + subtitle: Text.rich( + TextSpan( + children: [ + if (skills[i].skillType.isNotEmpty) + TextSpan( + text: skills[i].skillType, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: pokemonTypeColor(skills[i].skillType), + fontWeight: FontWeight.bold, + ), + ), + TextSpan( + text: + '${skills[i].skillType.isEmpty ? '' : ' '}${skills[i].category} · ' + 'PP ${skills[i].pp}/${skills[i].maxPp} · ${tr.skillPower}: ${skills[i].power}', + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.chevron_right, size: 18), + ReorderableDragStartListener(index: i, child: const Icon(Icons.drag_handle)), + ], + ), + onTap: _actionInProgress ? null : () => unawaited(_forget(skills[i])), + ), + ], + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _actionInProgress ? null : () => unawaited(_openEquipment(pokemon)), + icon: const Icon(Icons.shield_outlined), + label: Text(tr.equipment), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _actionInProgress + ? null + : () => unawaited(showPokemonEvolutionPath(context, _repository, pokemon.typeId)), + icon: const Icon(Icons.account_tree_outlined), + label: Text(tr.evolutionPath), + ), + ), + const SizedBox(height: 8), + if (!isActive) + FilledButton.icon( + onPressed: _actionInProgress ? null : _setFirst, + icon: const Icon(Icons.star_outline), + label: Text(tr.setFirst), + ), + ], + ), + ), + ); + } +} + +class _StatChip extends StatelessWidget { + const _StatChip({required this.label, required this.value}); + + final String label; + final int value; + + @override + Widget build(BuildContext context) { + return Chip( + visualDensity: VisualDensity.compact, + label: Text('$label: $value'), + ); + } +} diff --git a/lib/features/pokemon/view/pokemon_equipment_page.dart b/lib/features/pokemon/view/pokemon_equipment_page.dart new file mode 100644 index 00000000..a4e62489 --- /dev/null +++ b/lib/features/pokemon/view/pokemon_equipment_page.dart @@ -0,0 +1,250 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:tsdm_client/exceptions/exceptions.dart'; +import 'package:tsdm_client/features/pokemon/models/models.dart'; +import 'package:tsdm_client/features/pokemon/repository/pokemon_repository.dart'; +import 'package:tsdm_client/features/pokemon/utils/action_feedback.dart'; +import 'package:tsdm_client/features/pokemon/utils/pokemon_dialogs.dart'; +import 'package:tsdm_client/features/pokemon/utils/pokemon_image.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/cached_image/cached_image.dart'; +import 'package:tsdm_client/widgets/indicator.dart'; + +/// Equipment page of one pokemon: four slots, owned equipment and the equipment shop. +class PokemonEquipmentPage extends StatefulWidget { + /// Constructor. + const PokemonEquipmentPage({required this.pokemon, super.key}); + + /// The pokemon whose equipment is managed. + final Pokemon pokemon; + + @override + State createState() => _PokemonEquipmentPageState(); +} + +class _PokemonEquipmentPageState extends State { + final PokemonRepository _repository = PokemonRepository(); + EquipmentResponse? _data; + bool _loading = true; + bool _failed = false; + bool _busy = false; + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + /// Load the equipment; when [silent] the page keeps showing the current data instead of flashing a spinner. + Future _load({bool silent = false}) async { + if (!silent) { + setState(() { + _loading = true; + _failed = false; + }); + } + final result = await _repository.getEquipment(widget.pokemon.id).run(); + if (!mounted) return; + result.fold( + (_) => setState(() { + if (silent && _data != null) return; + _loading = false; + _failed = true; + }), + (data) => setState(() { + _data = data; + _loading = false; + _failed = false; + }), + ); + } + + /// Run a void action, report its message and reload on success. + Future _run(AsyncVoidEither Function() action, String successMessage) async { + if (_busy) return; + setState(() => _busy = true); + try { + final result = await action().run(); + if (!mounted) return; + result.fold((e) => _showSnack(pokemonErrorText(context, e)), (_) => _showSnack(successMessage)); + if (result.isRight()) await _load(silent: true); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _unequip(EquipmentSlot slot) async { + final tr = context.t.pokemon; + final confirmed = await showPokemonConfirmDialog( + context: context, + icon: Icons.remove_circle_outline, + title: tr.unequip, + message: '${tr.unequip}「${slot.item?.name ?? ''}」', + confirmLabel: tr.unequip, + ); + if (!confirmed || !mounted) return; + await _run(() => _repository.unequipItem(widget.pokemon.id, slot.slotIndex), tr.actionSuccess); + } + + Future _equip(OwnedEquipment item) async { + final tr = context.t.pokemon; + if (item.availableCount <= 0) { + _showSnack(tr.noEquipment); + return; + } + await _run(() => _repository.equipItem(widget.pokemon.id, item.myitemId), tr.actionSuccess); + } + + /// Pick owned equipment to equip into a specific (empty) [slot]. + Future _pickForSlot(EquipmentSlot slot) async { + final tr = context.t.pokemon; + final data = _data; + if (data == null) return; + final candidates = data.ownedItems.where((e) => e.availableCount > 0).toList(); + if (candidates.isEmpty) { + _showSnack(tr.noEquipment); + return; + } + final picked = await showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: ListView( + shrinkWrap: true, + children: [ + ListTile(title: Text(tr.equipSlot(index: slot.slotIndex + 1))), + const Divider(height: 1), + for (final item in candidates) + ListTile( + leading: _itemLeading(item.image), + title: Text(item.name), + subtitle: Text(_bonus(context, item)), + onTap: () => Navigator.of(context).pop(item), + ), + ], + ), + ), + ); + if (picked == null || !mounted) return; + await _run( + () => _repository.equipItem(widget.pokemon.id, picked.myitemId, slotIndex: slot.slotIndex), + tr.actionSuccess, + ); + } + + Future _buy(ShopEquipment item) async { + final tr = context.t.pokemon; + final confirmed = await showPokemonConfirmDialog( + context: context, + icon: Icons.shopping_cart_outlined, + title: tr.buy, + message: tr.buyPetConfirm(name: item.name, price: item.price), + confirmLabel: tr.buy, + ); + if (!confirmed || !mounted) return; + await _run(() => _repository.buy(item.typeId, 1), tr.actionSuccess); + } + + void _showSnack(String? message) { + showSnackBar(context: context, message: message ?? context.t.pokemon.actionFailed); + } + + /// Human readable bonus text of an equipment. + String _bonus(BuildContext context, EquipmentBonus e) { + final t = context.t.pokemon; + return [ + if (e.equipmentHp != 0) '${t.hp}+${e.equipmentHp}', + if (e.equipmentAtk != 0) '${t.attack}+${e.equipmentAtk}', + if (e.equipmentDef != 0) '${t.defense}+${e.equipmentDef}', + if (e.equipmentSpatk != 0) '${t.spAttack}+${e.equipmentSpatk}', + if (e.equipmentSpdef != 0) '${t.spDefense}+${e.equipmentSpdef}', + if (e.equipmentSd != 0) '${t.speed}+${e.equipmentSd}', + ].join(' '); + } + + Widget _itemLeading(String image) => SizedBox( + width: 40, + height: 40, + child: image.isEmpty + ? const Icon(Icons.shield_outlined) + : CachedImage(pokemonItemImageUrl(image), width: 40, height: 40, fit: BoxFit.contain), + ); + + @override + Widget build(BuildContext context) { + final tr = context.t.pokemon; + final data = _data; + return Scaffold( + appBar: AppBar(title: Text('${tr.equipment} · ${widget.pokemon.displayName}')), + // A landscape cutout (the front camera) sits on the left or right edge, so inset every side, not just the bottom + // the way a navigation bar alone would need. The app bar insets itself already. + body: SafeArea( + child: _loading + ? const CenteredCircularIndicator() + : _failed || data == null + ? Center(child: TextButton(onPressed: _load, child: Text(context.t.general.retry))) + : ListView( + padding: const EdgeInsets.all(16), + children: [ + Text('${tr.money}: ${data.userMoney}', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 16), + Text(tr.equipmentSlots, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + for (final slot in data.equipmentSlots) + ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: _itemLeading(slot.item?.image ?? ''), + title: Text(slot.item?.name ?? tr.equipSlot(index: slot.slotIndex + 1)), + subtitle: slot.item == null ? null : Text(_bonus(context, slot.item!)), + trailing: Icon(slot.item == null ? Icons.add_circle_outline : Icons.remove_circle_outline), + onTap: _busy + ? null + : slot.item == null + ? () => unawaited(_pickForSlot(slot)) + : () => unawaited(_unequip(slot)), + ), + const SizedBox(height: 16), + Text(tr.ownedEquipment, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + if (data.ownedItems.isEmpty) + Text(tr.noEquipment) + else + for (final item in data.ownedItems) + ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: _itemLeading(item.image), + title: Text(item.name), + subtitle: Text( + '${_bonus(context, item)} ×${item.availableCount}/${item.quantity}' + '${item.isEquipped ? ' · ${tr.equipped}' : ''}', + ), + trailing: const Icon(Icons.add_circle_outline), + onTap: _busy || item.availableCount <= 0 ? null : () => unawaited(_equip(item)), + ), + const SizedBox(height: 16), + Text(tr.equipmentShop, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + if (data.shopItems.isEmpty) + Text(tr.noEquipment) + else + for (final item in data.shopItems) + ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: _itemLeading(item.image), + title: Text(item.name), + subtitle: Text('${_bonus(context, item)} ${tr.price}: ${item.price}'), + trailing: FilledButton.tonal( + onPressed: _busy || !item.canBuy ? null : () => unawaited(_buy(item)), + child: Text(tr.buy), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/pokemon/view/pokemon_page.dart b/lib/features/pokemon/view/pokemon_page.dart new file mode 100644 index 00000000..f689d0b6 --- /dev/null +++ b/lib/features/pokemon/view/pokemon_page.dart @@ -0,0 +1,227 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import 'package:tsdm_client/constants/url.dart'; +import 'package:tsdm_client/extensions/build_context.dart'; +import 'package:tsdm_client/features/pokemon/cubit/pokemon_cubit.dart'; +import 'package:tsdm_client/features/pokemon/models/models.dart'; +import 'package:tsdm_client/features/pokemon/utils/action_feedback.dart'; +import 'package:tsdm_client/features/pokemon/view/inventory_tab.dart'; +import 'package:tsdm_client/features/pokemon/view/my_pokemon_tab.dart'; +import 'package:tsdm_client/features/pokemon/view/pokemon_center_tab.dart'; +import 'package:tsdm_client/features/pokemon/view/shop_tab.dart'; +import 'package:tsdm_client/features/pokemon/widgets/pokemon_count_label.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/routes/app_routes.dart'; +import 'package:tsdm_client/routes/screen_paths.dart'; +import 'package:tsdm_client/utils/retry_button.dart'; +import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/indicator.dart'; + +/// The pokemon center (宠物中心) page: my pokemon, healing, inventory and shop. +class PokemonPage extends StatefulWidget { + /// Constructor. + /// + /// [initialTab] selects the tab shown first (0 my pokemon, 1 the pokemon center, 2 inventory, 3 shop); the battle + /// result sends a player whose pet needs healing straight to the center with it. + const PokemonPage({this.initialTab = 0, super.key}); + + /// Index of the tab shown first. + final int initialTab; + + @override + State createState() => _PokemonPageState(); +} + +class _PokemonPageState extends State with WidgetsBindingObserver, RouteAware { + late final PokemonCubit _cubit; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _cubit = PokemonCubit(); + unawaited(_cubit.load()); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final route = ModalRoute.of(context); + if (route is PageRoute) routeObserver.subscribe(this, route); + } + + @override + void dispose() { + routeObserver.unsubscribe(this); + WidgetsBinding.instance.removeObserver(this); + unawaited(_cubit.close()); + super.dispose(); + } + + /// A page opened above this one (the battle page, a pet's detail, the storage) was popped. + /// + /// The party can have changed while it was on top — the battle page heals it on its way out — so read it again + /// instead of leaving the previous list on screen. + @override + void didPopNext() => unawaited(_cubit.refreshPokemons()); + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + // A status bar switch taken right before the app went away is often parked by the platform client; finish it here + // instead of leaving the player with a switch that did nothing. + if (state == AppLifecycleState.resumed) unawaited(_cubit.reconcileStatusBar()); + } + + @override + Widget build(BuildContext context) => BlocProvider.value( + value: _cubit, + child: BlocBuilder( + builder: (context, state) { + final tr = context.t.pokemon; + final (:carried, :boxed) = carriedAndBoxed(state.pokemons ?? const []); + return DefaultTabController( + length: 4, + initialIndex: widget.initialTab, + child: Scaffold( + appBar: AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(tr.title), + if (state.pokemons != null) + Text( + '${tr.carriedCount(count: carried)} · ${tr.boxCount(count: boxed)}', + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + actions: [ + IconButton( + icon: Icon(MdiIcons.swordCross), + tooltip: context.t.adventure.title, + onPressed: () async { + await context.pushNamed(ScreenPaths.pokemonAdventure); + if (mounted) await _cubit.refreshAll(); + }, + ), + IconButton( + icon: const Icon(Icons.refresh), + tooltip: tr.refresh, + onPressed: state.status == PokemonStatus.loading ? null : () => unawaited(_cubit.refreshAll()), + ), + IconButton( + icon: const Icon(Icons.open_in_browser_outlined), + tooltip: tr.openBrowser, + onPressed: () async => context.dispatchAsUrl('$baseUrl/plugin.php?id=pokemon:game', external: true), + ), + // Both the badge refresh and the status bar pet are forum-side settings, so keep them in an overflow + // menu instead of crowding the bar with five icons. + PopupMenuButton( + onSelected: (value) => unawaited(_onMenuSelected(context, state, value)), + itemBuilder: (context) => [ + PopupMenuItem( + value: 'badge', + child: ListTile( + leading: const Icon(Icons.military_tech_outlined), + title: Text(tr.refreshBadge), + contentPadding: EdgeInsets.zero, + ), + ), + // A plugin build that reports the status bar state gets one switch. The production build only has + // the website's web module, whose state cannot be read back, so it gets the website's two actions. + if (state.profile?.statusBarHidden != null) + CheckedPopupMenuItem( + value: 'statusToggle', + checked: state.profile?.statusBarHidden != true, + child: Text(tr.statusBarPet), + ), + if (state.profile?.statusBarHidden == null) ...[ + PopupMenuItem( + value: 'statusHide', + child: ListTile( + leading: const Icon(Icons.visibility_off_outlined), + title: Text(tr.statusBarHidePet), + contentPadding: EdgeInsets.zero, + ), + ), + PopupMenuItem( + value: 'statusRefresh', + child: ListTile( + leading: const Icon(Icons.refresh), + title: Text(tr.statusBarShowPet), + contentPadding: EdgeInsets.zero, + ), + ), + ], + ], + ), + ], + bottom: TabBar( + tabs: [ + Tab(icon: const Icon(Icons.pets), text: tr.myPokemon), + Tab(icon: const Icon(Icons.house_outlined), text: tr.pokemonCenter), + Tab(icon: const Icon(Icons.backpack_outlined), text: tr.inventory), + Tab(icon: const Icon(Icons.storefront_outlined), text: tr.shop), + ], + ), + ), + body: switch (state.status) { + PokemonStatus.loading => const CenteredCircularIndicator(), + PokemonStatus.needLogin => Center( + child: TextButton( + onPressed: () async { + await context.pushNamed(ScreenPaths.login); + if (mounted) await _cubit.load(); + }, + child: Text(tr.loginRequired), + ), + ), + PokemonStatus.failure => buildRetryButton(context, () => unawaited(_cubit.load()), message: tr.failedToLoad), + // The tabs are the app's main scrolling surfaces, so keep their content clear of the system bars. + PokemonStatus.success => const SafeArea( + child: TabBarView(children: [MyPokemonTab(), PokemonCenterTab(), InventoryTab(), ShopTab()]), + ), + }, + ), + ); + }, + ), + ); + + /// Run the action the overflow menu picked. + Future _onMenuSelected(BuildContext context, PokemonState state, String value) async { + switch (value) { + case 'badge': + await runPokemonAction(context, _cubit.refreshBadge); + case 'statusToggle': + await _setStatusBar(context, hide: state.profile?.statusBarHidden != true); + case 'statusHide': + await _setStatusBar(context, hide: true); + case 'statusRefresh': + await _setStatusBar(context, hide: false); + } + } + + /// Ask the server to show or hide the forum status bar pet and report the outcome. + Future _setStatusBar(BuildContext context, {required bool hide}) async { + final outcome = await _cubit.setStatusBar(hide: hide); + if (!context.mounted) return; + final tr = context.t.pokemon; + showSnackBar( + context: context, + message: switch (outcome) { + StatusBarOutcome.hidden => tr.statusBarPetHiddenDone, + StatusBarOutcome.shown => tr.statusBarPetShownDone, + StatusBarOutcome.notLoggedIn => tr.statusBarNeedLogin, + StatusBarOutcome.notApplied => tr.statusBarNotApplied, + StatusBarOutcome.failed => context.t.general.networkError, + }, + ); + } +} diff --git a/lib/features/pokemon/view/pokemon_storage_page.dart b/lib/features/pokemon/view/pokemon_storage_page.dart new file mode 100644 index 00000000..997b4993 --- /dev/null +++ b/lib/features/pokemon/view/pokemon_storage_page.dart @@ -0,0 +1,149 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tsdm_client/features/pokemon/models/models.dart'; +import 'package:tsdm_client/features/pokemon/repository/pokemon_repository.dart'; +import 'package:tsdm_client/features/pokemon/utils/action_feedback.dart'; +import 'package:tsdm_client/features/pokemon/utils/pokemon_image.dart'; +import 'package:tsdm_client/features/pokemon/utils/pokemon_style.dart'; +import 'package:tsdm_client/features/pokemon/widgets/pokemon_search_field.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/routes/screen_paths.dart'; +import 'package:tsdm_client/utils/retry_button.dart'; +import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/cached_image/cached_image.dart'; +import 'package:tsdm_client/widgets/indicator.dart'; + +/// The pokemon storage (仓库): pokemon not carried in the bag, with a name search. +class PokemonStoragePage extends StatefulWidget { + /// Constructor. + const PokemonStoragePage({super.key}); + + @override + State createState() => _PokemonStoragePageState(); +} + +class _PokemonStoragePageState extends State { + final PokemonRepository _repository = PokemonRepository(); + List? _pokemons; + bool _loading = true; + bool _failed = false; + bool _actionInProgress = false; + String _query = ''; + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + Future _load() async { + setState(() { + _loading = true; + _failed = false; + }); + final result = await _repository.getMyPokemon().run(); + if (!mounted) return; + result.fold( + (e) => setState(() { + _failed = true; + _loading = false; + }), + (list) => setState(() { + _pokemons = list.where((p) => p.isStored).toList(); + _loading = false; + }), + ); + } + + Future _putInBag(Pokemon pokemon) async { + if (_actionInProgress) return; + setState(() => _actionInProgress = true); + final tr = context.t.pokemon; + try { + final result = await _repository.movePokemon(pokemon.id, 2).run(); + if (!mounted) return; + final message = result.fold((e) => pokemonErrorText(context, e), (_) => tr.actionSuccess); + showSnackBar(context: context, message: message); + if (result.isRight()) await _load(); + } finally { + if (mounted) setState(() => _actionInProgress = false); + } + } + + /// A pokemon matches when its displayed name (possibly a nickname) or its species name contains the query. + bool _matches(Pokemon p) => _query.isEmpty || p.displayName.contains(_query) || p.name.contains(_query); + + @override + Widget build(BuildContext context) { + final tr = context.t.pokemon; + final pokemons = (_pokemons ?? const []).where(_matches).toList(); + final Widget body; + if (_loading) { + body = const CenteredCircularIndicator(); + } else if (_failed) { + body = buildRetryButton(context, () => unawaited(_load()), message: tr.failedToLoad); + } else { + body = Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 0), + child: PokemonSearchField(hintText: tr.searchPokemon, onChanged: (value) => setState(() => _query = value)), + ), + Expanded( + child: RefreshIndicator( + onRefresh: _load, + child: pokemons.isEmpty + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [Padding(padding: const EdgeInsets.all(24), child: Center(child: Text(tr.empty)))], + ) + : ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(8), + itemCount: pokemons.length, + itemBuilder: (context, index) { + final pokemon = pokemons[index]; + return Card( + child: ListTile( + onTap: () async { + await context.pushNamed(ScreenPaths.pokemonDetail, extra: pokemon); + if (mounted) await _load(); + }, + leading: SizedBox( + width: 48, + height: 48, + child: CachedImage( + pokemonImageUrl(pokemon.typeId), + width: 48, + height: 48, + fit: BoxFit.contain, + ), + ), + title: Text(pokemon.displayName, overflow: TextOverflow.ellipsis), + subtitle: Text('${tr.level(level: pokemon.level)} · ${pokemonStateText(context, pokemon.state, pokemon.stateText)}'), + trailing: FilledButton.tonal( + onPressed: _actionInProgress ? null : () => unawaited(_putInBag(pokemon)), + child: Text(tr.putInBag), + ), + ), + ); + }, + ), + ), + ), + ], + ); + } + return Scaffold( + appBar: AppBar( + title: Text(tr.storage), + actions: [ + IconButton(icon: const Icon(Icons.refresh), tooltip: tr.refresh, onPressed: _loading ? null : () => unawaited(_load())), + ], + ), + body: SafeArea(child: body), + ); + } +} diff --git a/lib/features/pokemon/view/shop_tab.dart b/lib/features/pokemon/view/shop_tab.dart new file mode 100644 index 00000000..fe2aaafd --- /dev/null +++ b/lib/features/pokemon/view/shop_tab.dart @@ -0,0 +1,305 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:tsdm_client/features/pokemon/cubit/pokemon_cubit.dart'; +import 'package:tsdm_client/features/pokemon/models/models.dart'; +import 'package:tsdm_client/features/pokemon/utils/action_feedback.dart'; +import 'package:tsdm_client/features/pokemon/utils/pokemon_dialogs.dart'; +import 'package:tsdm_client/features/pokemon/utils/pokemon_image.dart'; +import 'package:tsdm_client/features/pokemon/widgets/pokemon_grid_cell.dart'; +import 'package:tsdm_client/features/pokemon/widgets/pokemon_search_field.dart'; +import 'package:tsdm_client/features/pokemon/widgets/shop_category_chips.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/widgets/cached_image/cached_image.dart'; + +/// "Shop" tab: browse and buy items by category (or whole pokemon), with search and a grid mode. +class ShopTab extends StatefulWidget { + /// Constructor. + const ShopTab({super.key}); + + @override + State createState() => _ShopTabState(); +} + +class _ShopTabState extends State { + final ScrollController _controller = ScrollController(); + bool _loadingMore = false; + bool _grid = false; + String _query = ''; + ShopCategory? _lastCategory; + + @override + void initState() { + super.initState(); + _controller.addListener(_onScroll); + } + + @override + void dispose() { + _controller + ..removeListener(_onScroll) + ..dispose(); + super.dispose(); + } + + /// Load the next page when the list nears its end (pagination). + Future _onScroll() async { + if (_loadingMore || !_controller.hasClients) return; + final position = _controller.position; + if (position.pixels < position.maxScrollExtent - 200) return; + setState(() => _loadingMore = true); + try { + await context.read().loadMoreShop(); + } finally { + if (mounted) setState(() => _loadingMore = false); + } + } + + /// Search only filters what is loaded, so pull the remaining pages first. + void _onSearch(String value) { + setState(() => _query = value); + if (_query.isEmpty) return; + unawaited(context.read().loadAllShop()); + } + + /// Scroll back to the top when the selected category changes, so the new list starts at its head. + void _resetIfCategoryChanged(ShopCategory category) { + if (_lastCategory == category) return; + _lastCategory = category; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_controller.hasClients) _controller.jumpTo(0); + }); + } + + /// Trailing loading cell shown while the next page is fetched. + Widget _footer() => Padding( + padding: const EdgeInsets.all(12), + child: Center( + child: _loadingMore + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + : const SizedBox.shrink(), + ), + ); + + @override + Widget build(BuildContext context) { + final state = context.watch().state; + final cubit = context.read(); + final tr = context.t.pokemon; + _resetIfCategoryChanged(state.shopCategory); + final categories = <(ShopCategory, String)>[ + (ShopCategory.all, tr.shopCategoryAll), + (ShopCategory.med, tr.shopCategoryMed), + (ShopCategory.ball, tr.shopCategoryBall), + (ShopCategory.stone, tr.shopCategoryStone), + (ShopCategory.boost, tr.shopCategoryBoost), + (ShopCategory.equipment, tr.shopCategoryEquipment), + (ShopCategory.pet, tr.shopCategoryPet), + ]; + return Column( + children: [ + if (state.profile != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Row( + children: [ + const Icon(Icons.monetization_on_outlined), + const SizedBox(width: 8), + Text('${tr.money}: ${state.profile!.money ?? 0}'), + ], + ), + ), + PokemonCategoryChips( + categories: categories, + selected: state.shopCategory, + onSelected: (category) => unawaited(cubit.setShopCategory(category)), + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 8, 4), + child: Row( + children: [ + Expanded(child: PokemonSearchField(hintText: tr.searchItems, onChanged: _onSearch)), + IconButton( + tooltip: _grid ? tr.viewList : tr.viewGrid, + icon: Icon(_grid ? Icons.view_list_outlined : Icons.grid_view_outlined), + onPressed: () => setState(() => _grid = !_grid), + ), + ], + ), + ), + Expanded( + child: RefreshIndicator( + onRefresh: cubit.refreshAll, + child: state.shopCategory.isPet ? _buildPets(context, state) : _buildItems(context, state), + ), + ), + ], + ); + } + + /// The items currently shown: the loaded page filtered by the search query. + List _visibleItems(PokemonState state) { + final items = state.shop?.items ?? const []; + if (_query.isEmpty) return items; + return items.where((e) => e.name.contains(_query) || e.description.contains(_query)).toList(); + } + + /// The pets currently shown: the loaded page filtered by the search query. + List _visiblePets(PokemonState state) { + final pets = state.shopPets?.pets ?? const []; + if (_query.isEmpty) return pets; + return pets.where((e) => e.name.contains(_query)).toList(); + } + + /// Placeholder list used when there is nothing to show. + Widget _empty() => ListView( + controller: _controller, + physics: const AlwaysScrollableScrollPhysics(), + children: [Padding(padding: const EdgeInsets.all(24), child: Center(child: Text(context.t.pokemon.empty)))], + ); + + Widget _buildItems(BuildContext context, PokemonState state) { + final tr = context.t.pokemon; + final items = _visibleItems(state); + if (items.isEmpty) return _empty(); + if (_grid) { + return GridView.builder( + controller: _controller, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(8), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisExtent: 116, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + ), + itemCount: items.length + 1, + itemBuilder: (context, index) => index >= items.length + ? _footer() + : PokemonGridCell( + image: items[index].image.isEmpty ? null : pokemonItemImageUrl(items[index].image), + fallbackIcon: Icons.shopping_bag_outlined, + title: items[index].name, + subtitle: '${tr.price}: ${items[index].price}', + onTap: items[index].canBuy ? () => _buy(items[index]) : null, + ), + ); + } + return ListView.builder( + controller: _controller, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(8), + itemCount: items.length + 1, + itemBuilder: (context, index) { + if (index >= items.length) return _footer(); + final item = items[index]; + return Card( + child: ListTile( + leading: SizedBox( + width: 40, + height: 40, + child: item.image.isEmpty + ? const Icon(Icons.shopping_bag_outlined) + : CachedImage(pokemonItemImageUrl(item.image), width: 40, height: 40, fit: BoxFit.contain), + ), + title: Text(item.name), + subtitle: Text('${tr.price}: ${item.price}\n${item.description}'), + trailing: FilledButton.tonal(onPressed: item.canBuy ? () => _buy(item) : null, child: Text(tr.buy)), + ), + ); + }, + ); + } + + Widget _buildPets(BuildContext context, PokemonState state) { + final tr = context.t.pokemon; + final pets = _visiblePets(state); + if (pets.isEmpty) return _empty(); + if (_grid) { + return GridView.builder( + controller: _controller, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(8), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisExtent: 120, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + ), + itemCount: pets.length + 1, + itemBuilder: (context, index) => index >= pets.length + ? _footer() + : PokemonGridCell( + image: pokemonImageUrl(pets[index].id), + fallbackIcon: Icons.catching_pokemon, + title: pets[index].name, + subtitle: '${tr.price}: ${pets[index].price}', + onTap: pets[index].canBuy ? () => _buyPet(pets[index]) : null, + ), + ); + } + return ListView.builder( + controller: _controller, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(8), + itemCount: pets.length + 1, + itemBuilder: (context, index) { + if (index >= pets.length) return _footer(); + final pet = pets[index]; + final types = pet.type2 == null || pet.type2!.isEmpty ? pet.type1 : '${pet.type1}/${pet.type2}'; + return Card( + child: ListTile( + leading: SizedBox( + width: 40, + height: 40, + child: CachedImage(pokemonImageUrl(pet.id), width: 40, height: 40, fit: BoxFit.contain), + ), + title: Text(pet.name), + subtitle: Text( + '$types\n' + '${tr.hp} ${pet.hp} · ${tr.attack} ${pet.atk} · ${tr.defense} ${pet.def} · ${tr.speed} ${pet.speed}\n' + '${tr.price}: ${pet.price}', + ), + isThreeLine: true, + trailing: FilledButton.tonal(onPressed: pet.canBuy ? () => _buyPet(pet) : null, child: Text(tr.buy)), + ), + ); + }, + ); + } + + Future _buy(ShopItem item) async { + // Use the tab's own (stable) context and grab the cubit before the dialog: the list item's context may already be + // gone by the time the dialog closes and the list rebuilds. + final cubit = context.read(); + final tr = context.t.pokemon; + final quantityText = await showPokemonInputDialog( + context: context, + icon: Icons.shopping_cart_outlined, + title: '${tr.buy}: ${item.name}', + initialValue: '1', + confirmLabel: tr.buy, + hintText: tr.quantity, + keyboardType: TextInputType.number, + ); + if (quantityText == null || !mounted) return; + final quantity = int.tryParse(quantityText) ?? 1; + if (quantity <= 0) return; + await runPokemonAction(context, () => cubit.buy(item.id, quantity)); + } + + Future _buyPet(ShopPet pet) async { + final cubit = context.read(); + final tr = context.t.pokemon; + final confirmed = await showPokemonConfirmDialog( + context: context, + icon: Icons.shopping_cart_outlined, + title: tr.buy, + message: tr.buyPetConfirm(name: pet.name, price: pet.price), + confirmLabel: tr.buy, + ); + if (!confirmed || !mounted) return; + await runPokemonAction(context, () => cubit.buyPet(pet.id)); + } +} diff --git a/lib/features/pokemon/widgets/pokemon_count_label.dart b/lib/features/pokemon/widgets/pokemon_count_label.dart new file mode 100644 index 00000000..d1a20be6 --- /dev/null +++ b/lib/features/pokemon/widgets/pokemon_count_label.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:tsdm_client/features/pokemon/models/models.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; + +/// Counts the carried (party) and boxed (storage) pokemon in [pokemons]. +({int carried, int boxed}) carriedAndBoxed(Iterable pokemons) { + final boxed = pokemons.where((p) => p.isStored).length; + return (carried: pokemons.length - boxed, boxed: boxed); +} + +/// A small "carried: N · box: M" line shown above the pokemon lists. +class PokemonCountLabel extends StatelessWidget { + /// Constructor. + const PokemonCountLabel({required this.carried, required this.boxed, super.key}); + + /// How many pokemon are carried in the bag. + final int carried; + + /// How many pokemon are in the storage box. + final int boxed; + + @override + Widget build(BuildContext context) { + final tr = context.t.pokemon; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Row( + children: [ + Icon(Icons.inventory_2_outlined, size: 16, color: Theme.of(context).colorScheme.outline), + const SizedBox(width: 6), + Text( + '${tr.carriedCount(count: carried)} · ${tr.boxCount(count: boxed)}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ); + } +} diff --git a/lib/features/pokemon/widgets/pokemon_evolution_dialog.dart b/lib/features/pokemon/widgets/pokemon_evolution_dialog.dart new file mode 100644 index 00000000..6ceb3f4e --- /dev/null +++ b/lib/features/pokemon/widgets/pokemon_evolution_dialog.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tsdm_client/features/pokemon/models/models.dart'; +import 'package:tsdm_client/features/pokemon/repository/pokemon_repository.dart'; +import 'package:tsdm_client/features/pokemon/utils/pokemon_image.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/widgets/cached_image/cached_image.dart'; + +/// Show the evolution chain of species [speciesId] the way the website's "进化路径" dialog does: what the pokemon can +/// become and what it evolved from, with each step's condition. +/// +/// Display only, exactly like the website: it reads `evolution&action=evolution_path` and never triggers an evolution. +Future showPokemonEvolutionPath(BuildContext context, PokemonRepository repository, int speciesId) async { + final result = await repository.getEvolutionPath(speciesId).run(); + if (!context.mounted) return; + final path = result.fold((_) => null, (value) => value); + if (path == null) return; + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(context.t.pokemon.evolutionPath), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (path.isEmpty) Text(context.t.pokemon.evolutionNone), + if (path.backward.isNotEmpty) ...[ + Text(context.t.pokemon.evolutionBackward, style: Theme.of(context).textTheme.labelLarge), + for (final step in path.backward) _EvolutionStepTile(step: step), + ], + if (path.forward.isNotEmpty) ...[ + if (path.backward.isNotEmpty) const SizedBox(height: 12), + Text(context.t.pokemon.evolutionForward, style: Theme.of(context).textTheme.labelLarge), + for (final step in path.forward) _EvolutionStepTile(step: step), + ], + ], + ), + ), + actions: [TextButton(onPressed: () => context.pop(), child: Text(context.t.battle.ok))], + ), + ); +} + +/// One row of the evolution dialog: the form's sprite, its name and the condition to reach it. +class _EvolutionStepTile extends StatelessWidget { + const _EvolutionStepTile({required this.step}); + + /// The evolution step to show. + final EvolutionStep step; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + CachedImage(pokemonSmallImageUrl(step.id), width: 36, height: 36, fit: BoxFit.contain), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(step.name, style: Theme.of(context).textTheme.bodyLarge), + if (step.conditionDisplay.isNotEmpty) + Text(step.conditionDisplay, style: Theme.of(context).textTheme.bodySmall), + ], + ), + ), + ], + ), + ); +} diff --git a/lib/features/pokemon/widgets/pokemon_grid_cell.dart b/lib/features/pokemon/widgets/pokemon_grid_cell.dart new file mode 100644 index 00000000..cd921737 --- /dev/null +++ b/lib/features/pokemon/widgets/pokemon_grid_cell.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:tsdm_client/widgets/cached_image/cached_image.dart'; + +/// One small-icon grid cell shared by the inventory and the shop. +class PokemonGridCell extends StatelessWidget { + /// Constructor. + const PokemonGridCell({ + required this.image, + required this.fallbackIcon, + required this.title, + required this.subtitle, + required this.onTap, + super.key, + }); + + /// Image url to show, or null to show [fallbackIcon]. + final String? image; + + /// Icon shown in place of a missing image. + final IconData fallbackIcon; + + /// First line: the item or pokemon name. + final String title; + + /// Second line: usually the price or the owned quantity. + final String subtitle; + + /// Tap action; null disables the cell. + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(6), + child: Column( + children: [ + Expanded(child: image == null ? Icon(fallbackIcon) : CachedImage(image!, fit: BoxFit.contain)), + const SizedBox(height: 4), + Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.labelSmall), + Text(subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.labelSmall), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/pokemon/widgets/pokemon_hp_bar.dart b/lib/features/pokemon/widgets/pokemon_hp_bar.dart new file mode 100644 index 00000000..3b9e3aec --- /dev/null +++ b/lib/features/pokemon/widgets/pokemon_hp_bar.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; + +/// A small HP bar with the `HP x/y` label, shared by the pokemon lists. +class PokemonHpBar extends StatelessWidget { + /// Constructor. + const PokemonHpBar({required this.hp, required this.maxHp, super.key}); + + /// Current HP. + final int hp; + + /// Maximum HP. + final int maxHp; + + @override + Widget build(BuildContext context) { + final ratio = maxHp <= 0 ? 0.0 : (hp / maxHp).clamp(0.0, 1.0); + final color = ratio > 0.5 ? Colors.green : ratio > 0.2 ? Colors.orange : Colors.red; + return Row( + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: ratio, + minHeight: 6, + color: color, + backgroundColor: color.withValues(alpha: 0.2), + ), + ), + ), + const SizedBox(width: 8), + Text('${context.t.pokemon.hp} $hp/$maxHp', style: Theme.of(context).textTheme.bodySmall), + ], + ); + } +} diff --git a/lib/features/pokemon/widgets/pokemon_search_field.dart b/lib/features/pokemon/widgets/pokemon_search_field.dart new file mode 100644 index 00000000..996a025c --- /dev/null +++ b/lib/features/pokemon/widgets/pokemon_search_field.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +/// The standard pokemon search field used by the inventory, the shop, the pokemon center and the storage. +/// +/// It owns its controller and clear button, and reports the trimmed query through [onChanged], so callers only keep the +/// current query string instead of another controller. +class PokemonSearchField extends StatefulWidget { + /// Constructor. + const PokemonSearchField({required this.hintText, required this.onChanged, super.key}); + + /// Placeholder shown while the field is empty. + final String hintText; + + /// Called with the trimmed query whenever it changes, including when the field is cleared. + final ValueChanged onChanged; + + @override + State createState() => _PokemonSearchFieldState(); +} + +class _PokemonSearchFieldState extends State { + final TextEditingController _controller = TextEditingController(); + bool _hasText = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + /// Report the new value and keep the clear button in sync. + void _update(String value) { + setState(() => _hasText = value.isNotEmpty); + widget.onChanged(value.trim()); + } + + @override + Widget build(BuildContext context) { + return TextField( + controller: _controller, + onChanged: _update, + decoration: InputDecoration( + isDense: true, + prefixIcon: const Icon(Icons.search), + hintText: widget.hintText, + suffixIcon: _hasText + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _controller.clear(); + _update(''); + }, + ) + : null, + ), + ); + } +} diff --git a/lib/features/pokemon/widgets/shop_category_chips.dart b/lib/features/pokemon/widgets/shop_category_chips.dart new file mode 100644 index 00000000..06486f18 --- /dev/null +++ b/lib/features/pokemon/widgets/shop_category_chips.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:tsdm_client/features/pokemon/cubit/pokemon_cubit.dart'; + +/// The horizontally scrolling category chips shared by the shop and the inventory. +class PokemonCategoryChips extends StatelessWidget { + /// Constructor. + const PokemonCategoryChips({required this.categories, required this.selected, required this.onSelected, super.key}); + + /// Available categories with their labels, in display order. + final List<(ShopCategory, String)> categories; + + /// The category currently shown as selected. + final ShopCategory selected; + + /// Called when the user taps a category. + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), + child: Row( + children: [ + for (final (category, label) in categories) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: ChoiceChip( + label: Text(label), + selected: selected == category, + onSelected: (_) => onSelected(category), + ), + ), + ], + ), + ); + } +} From 43a7b4e216463ce30bdc06b636383a55e18768d4 Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 12:58:28 +0800 Subject: [PATCH 06/37] feat(pokemon): wire routes and entry points Registers the adventure cache, adds the /pokemon routes and their observer, and puts the centre behind the home app bar, the home tools card and the profile page. --- lib/features/homepage/view/homepage_page.dart | 5 ++++ .../homepage/widgets/home_dashboard.dart | 1 + lib/features/profile/view/profile_page.dart | 7 +++++ lib/routes/app_routes.dart | 28 ++++++++++++++++++- lib/routes/screen_paths.dart | 24 ++++++++++++++++ lib/shared/providers/providers.dart | 4 ++- 6 files changed, 67 insertions(+), 2 deletions(-) diff --git a/lib/features/homepage/view/homepage_page.dart b/lib/features/homepage/view/homepage_page.dart index 2be8aaef..e54ab7f3 100644 --- a/lib/features/homepage/view/homepage_page.dart +++ b/lib/features/homepage/view/homepage_page.dart @@ -375,6 +375,11 @@ class _HomepagePageState extends State { if (username != null) ...[ if (showDailyActionsInBar) const CheckinButton(enableSnackBar: true), const NoticeButton(), + IconButton( + icon: const Icon(Icons.catching_pokemon), + tooltip: context.t.pokemon.title, + onPressed: () async => context.pushNamed(ScreenPaths.pokemon), + ), IconButton( icon: SizedBox( width: 32, diff --git a/lib/features/homepage/widgets/home_dashboard.dart b/lib/features/homepage/widgets/home_dashboard.dart index 6ade7c5a..bab51b0b 100644 --- a/lib/features/homepage/widgets/home_dashboard.dart +++ b/lib/features/homepage/widgets/home_dashboard.dart @@ -534,6 +534,7 @@ class HomeToolsCard extends StatelessWidget { (Icons.star_outline, tr.welcome.favorite, ScreenPaths.favorite), (Icons.history_outlined, tr.welcome.history, ScreenPaths.threadVisitHistory), (Icons.account_balance_outlined, context.t.bank.title, ScreenPaths.bank), + (Icons.catching_pokemon, context.t.pokemon.title, ScreenPaths.pokemon), ]; return Card( margin: EdgeInsets.zero, diff --git a/lib/features/profile/view/profile_page.dart b/lib/features/profile/view/profile_page.dart index f1e69005..30852b70 100644 --- a/lib/features/profile/view/profile_page.dart +++ b/lib/features/profile/view/profile_page.dart @@ -802,6 +802,13 @@ class _ProfilePageState extends State { trailing: const Icon(Icons.chevron_right), onTap: () async => context.pushNamed(ScreenPaths.bank), ), + const Divider(height: 1, indent: 16, endIndent: 16), + ListTile( + leading: const Icon(Icons.catching_pokemon), + title: Text(context.t.pokemon.title), + trailing: const Icon(Icons.chevron_right), + onTap: () async => context.pushNamed(ScreenPaths.pokemon), + ), ], ), ), diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart index 1ba2533f..4a9135f6 100644 --- a/lib/routes/app_routes.dart +++ b/lib/routes/app_routes.dart @@ -34,6 +34,13 @@ import 'package:tsdm_client/features/notification/view/notification_sync_all_pag import 'package:tsdm_client/features/open_in_app/view/open_in_app_page.dart'; import 'package:tsdm_client/features/packet/view/packet_detail_page.dart'; import 'package:tsdm_client/features/points/views/points_page.dart'; +import 'package:tsdm_client/features/pokemon/models/models.dart'; +import 'package:tsdm_client/features/pokemon/view/adventure_page.dart'; +import 'package:tsdm_client/features/pokemon/view/battle_page.dart'; +import 'package:tsdm_client/features/pokemon/view/pokemon_detail_page.dart'; +import 'package:tsdm_client/features/pokemon/view/pokemon_equipment_page.dart'; +import 'package:tsdm_client/features/pokemon/view/pokemon_page.dart'; +import 'package:tsdm_client/features/pokemon/view/pokemon_storage_page.dart'; import 'package:tsdm_client/features/post/models/models.dart'; import 'package:tsdm_client/features/post/models/poll_create.dart'; import 'package:tsdm_client/features/post/view/fast_reply_edit_template_page.dart'; @@ -71,10 +78,14 @@ import 'package:tsdm_client/shared/models/models.dart'; /// Tracks root dialogs so external navigation respects their modal barriers. final popupRouteObserver = PopupRouteObserver(); +/// Tracks pushed pages, so a page that is only covered (the pokemon centre, while the battle page is on top of it) can +/// refresh itself when it is shown again. +final routeObserver = RouteObserver>(); + /// App router instance wrapped with global singleton widgets. final router = GoRouter( initialLocation: ScreenPaths.homepage, - observers: [popupRouteObserver], + observers: [popupRouteObserver, routeObserver], routes: _appRoutes, ); @@ -304,6 +315,21 @@ final List _appRoutes = [ AppRoute(path: ScreenPaths.medalCenter, builder: (_) => const MedalCenterPage()), AppRoute(path: ScreenPaths.medalTitleHub, builder: (_) => const MedalTitleHubPage()), AppRoute(path: ScreenPaths.bank, builder: (_) => const BankPage()), + AppRoute(path: ScreenPaths.pokemon, builder: (state) => PokemonPage(initialTab: (state.extra as int?) ?? 0)), + AppRoute( + path: ScreenPaths.pokemonDetail, + builder: (state) => PokemonDetailPage(pokemon: state.extra! as Pokemon), + ), + AppRoute(path: ScreenPaths.pokemonAdventure, builder: (_) => const AdventurePage()), + AppRoute( + path: ScreenPaths.pokemonBattle, + builder: (state) => BattlePage(args: state.extra! as BattlePageArgs), + ), + AppRoute(path: ScreenPaths.pokemonStorage, builder: (_) => const PokemonStoragePage()), + AppRoute( + path: ScreenPaths.pokemonEquipment, + builder: (state) => PokemonEquipmentPage(pokemon: state.extra! as Pokemon), + ), AppRoute(path: ScreenPaths.editUserProfile, builder: (_) => const EditUserProfilePage()), AppRoute( path: ScreenPaths.ratePost, diff --git a/lib/routes/screen_paths.dart b/lib/routes/screen_paths.dart index f231f41f..1ab16f23 100644 --- a/lib/routes/screen_paths.dart +++ b/lib/routes/screen_paths.dart @@ -92,6 +92,24 @@ class ScreenPaths { /// Community bank balances, records and current-account transactions. static const String bank = '/bank'; + /// The pokemon center (宠物中心): my pokemon, healing, inventory and shop. + static const String pokemon = '/pokemon'; + + /// Detail page of a single pokemon; receives the pokemon object via `extra`. + static const String pokemonDetail = '/pokemon/detail'; + + /// The adventure (冒险) map list. + static const String pokemonAdventure = '/pokemon/adventure'; + + /// The battle page; receives a `BattlePageArgs` via `extra`. + static const String pokemonBattle = '/pokemon/battle'; + + /// The pokemon storage (仓库) page: pokemon not carried in the bag. + static const String pokemonStorage = '/pokemon/storage'; + + /// The equipment page of one pokemon; receives the `Pokemon` via `extra`. + static const String pokemonEquipment = '/pokemon/equipment'; + /// Page to edit current user's profile. static const String editUserProfile = '/editUserProfile'; @@ -404,6 +422,12 @@ class DialogPaths { /// Dialog showing a red packet in a thread. static const String redPacket = '/dialog/redPacket'; + + /// Dialog confirming a pokemon action (release / forget skill / unequip / buy). + static const String pokemonConfirm = '/dialog/pokemonConfirm'; + + /// Dialog inputting pokemon related text (rename or buy quantity). + static const String pokemonInput = '/dialog/pokemonInput'; } /// Route path for a screen. diff --git a/lib/shared/providers/providers.dart b/lib/shared/providers/providers.dart index d26e0a89..b126b73f 100644 --- a/lib/shared/providers/providers.dart +++ b/lib/shared/providers/providers.dart @@ -1,4 +1,5 @@ import 'package:system_network_proxy/system_network_proxy.dart'; +import 'package:tsdm_client/features/pokemon/repository/adventure_cache.dart'; import 'package:tsdm_client/features/settings/repositories/settings_repository.dart'; import 'package:tsdm_client/instance.dart'; import 'package:tsdm_client/shared/providers/cookie_provider/cookie_provider.dart'; @@ -58,7 +59,8 @@ Future initProviders() async { ..registerSingleton(ImageCacheProvider.new) ..registerFactory(NetClientProvider.build) ..registerFactory(NetClientProvider.buildNoCookie, instanceName: ServiceKeys.noCookie) - ..registerSingleton(NetErrorSaver()); + ..registerSingleton(NetErrorSaver()) + ..registerSingleton(AdventureCache()); await getIt.allReady(); getIt.registerSingleton( From 5c4c7c2389ba2342472ec4861da686d02b19a2f5 Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 12:58:29 +0800 Subject: [PATCH 07/37] fix(cache): never fail an image load because its cache write failed Android can clear the cache directory while the app runs, so it is created again before a write and a failing write only costs the cache entry, not the image. --- .../image_cache_provider/image_cache_provider.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/shared/providers/image_cache_provider/image_cache_provider.dart b/lib/shared/providers/image_cache_provider/image_cache_provider.dart index d0ffa00b..1744ca5a 100644 --- a/lib/shared/providers/image_cache_provider/image_cache_provider.dart +++ b/lib/shared/providers/image_cache_provider/image_cache_provider.dart @@ -392,7 +392,14 @@ final class ImageCacheProvider with LoggerMixin { if (cache == null) { return; } - await cache.writeAsBytes(imageData); + // Android can clear this directory while the app runs (storage pressure, or the cache screen), so make sure it is + // there again — and never let a cache write fail the image being loaded: its bytes are already in hand. + try { + await cache.parent.create(recursive: true); + await cache.writeAsBytes(imageData); + } on FileSystemException catch (e) { + warning('could not save the image cache (${imageUrl.length} char url): ${e.message}'); + } // Update other cache ref tables, if necessary. switch (usage) { From 8b9bfe01943627ca6a7e411c3402cbf654488b3c Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 12:58:29 +0800 Subject: [PATCH 08/37] fix(ui): skip a snack bar once its messenger is gone A bar queued on a messenger whose page was just torn down trips an assertion inside it; skip it instead. --- lib/utils/show_toast.dart | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/utils/show_toast.dart b/lib/utils/show_toast.dart index 9d31e39d..b737df7b 100644 --- a/lib/utils/show_toast.dart +++ b/lib/utils/show_toast.dart @@ -43,15 +43,24 @@ void showSnackBar({ // label and a close icon crossed that on 360dp phones (issue #4), so callers can raise the threshold. actionOverflowThreshold: actionOverflowThreshold, ); + /// Show [bar] on the app-level messenger, unless that messenger was torn down meanwhile. + void show() { + final current = snackbarKey.currentState; + // A messenger whose page was just torn down trips the "deactivated widget's ancestor" assertion inside it, so skip + // the bar instead of queueing it on a dead element. + if (current == null || !current.mounted) return; + current.showSnackBar(bar); + } + try { - messenger?.showSnackBar(bar); + show(); // Debug builds assert inside the messenger when one of its scaffolds is being torn down in this very frame (seen // on the chat pages while the reply sheet closed). Show the bar once the tree settled instead of dropping it // together with whatever the caller does next. // ignore: avoid_catching_errors } on FlutterError { WidgetsBinding.instance - ..addPostFrameCallback((_) => messenger?.showSnackBar(bar)) + ..addPostFrameCallback((_) => show()) ..ensureVisualUpdate(); } } From 4e5b17fa856b84c5f2fab4aec442d566b5dad124 Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 12:58:32 +0800 Subject: [PATCH 09/37] chore: ignore the coverage output flutter test --coverage writes a coverage directory that is easy to add by accident. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 5393808f..a2e6148f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ schema_versions.dart /web/sqlite3.debug.wasm /web/sqlite3.wasm +# Coverage output of `flutter test --coverage` +/coverage/ + # Genetated files /lib/generated *.g.dart From bc975d06298086d85c9690abc6bd545b5e47c735 Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 12:58:33 +0800 Subject: [PATCH 10/37] test(pokemon): add the pokemon centre regression tests Models and url building, the adventure/battle/skill/shop models and the error text mapping, the dialogs, the repository (client reuse, one formhash read per page load, party healing, a call that never answers), the cubits (lifecycle, status bar switch, item actions, the battle re-read after a lost answer), the pure helpers, and the landscape layout of the centre, the adventure page and the battle page (renumbered to 176-183). --- test/regression/test_176_pokemon_test.dart | 227 ++++++++++ ...est_177_pokemon_adventure_battle_test.dart | 176 ++++++++ ...est_178_pokemon_skills_equipment_test.dart | 414 ++++++++++++++++++ .../test_179_pokemon_dialogs_test.dart | 111 +++++ .../test_180_pokemon_party_healing_test.dart | 206 +++++++++ ...test_181_pokemon_cubit_lifecycle_test.dart | 223 ++++++++++ .../test_182_pokemon_pure_helpers_test.dart | 96 ++++ .../test_183_pokemon_landscape_test.dart | 222 ++++++++++ 8 files changed, 1675 insertions(+) create mode 100644 test/regression/test_176_pokemon_test.dart create mode 100644 test/regression/test_177_pokemon_adventure_battle_test.dart create mode 100644 test/regression/test_178_pokemon_skills_equipment_test.dart create mode 100644 test/regression/test_179_pokemon_dialogs_test.dart create mode 100644 test/regression/test_180_pokemon_party_healing_test.dart create mode 100644 test/regression/test_181_pokemon_cubit_lifecycle_test.dart create mode 100644 test/regression/test_182_pokemon_pure_helpers_test.dart create mode 100644 test/regression/test_183_pokemon_landscape_test.dart diff --git a/test/regression/test_176_pokemon_test.dart b/test/regression/test_176_pokemon_test.dart new file mode 100644 index 00000000..b9c655e0 --- /dev/null +++ b/test/regression/test_176_pokemon_test.dart @@ -0,0 +1,227 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tsdm_client/features/pokemon/models/models.dart'; +import 'package:tsdm_client/features/pokemon/utils/pokemon_image.dart'; + +/// Tests of the pokemon (宠物中心) plugin JSON models and image url builders. +/// +/// The plugin answers `plugin.php?id=pokemon:pokemon&endpoint=X&action=Y` with a `{success, data, ...}` envelope whose +/// `data` object shapes are mirrored here from `pokemon_system/api/*.php`. +void main() { + group('PokemonMapper', () { + test('decodes a list item', () { + final pokemon = Pokemon.fromMap(_pokemonMap); + expect(pokemon.id, 123); + expect(pokemon.name, '皮卡丘'); + expect(pokemon.typeId, 25); + expect(pokemon.level, 50); + expect(pokemon.isShiny, isFalse); + expect(pokemon.stateText, '正常'); + expect(pokemon.displayName, '皮卡丘'); + expect(pokemon.skills, hasLength(4)); + expect(pokemon.skills.first.name, '撞击'); + expect(pokemon.skills.last.isEmpty, isTrue); + expect(pokemon.baseInfo.type1, '电'); + expect(pokemon.baseInfo.type2, isNull); + expect(pokemon.stats, isNull); + }); + + test('uses the nickname when set', () { + final pokemon = Pokemon.fromMap({..._pokemonMap, 'nickname': '雷电球'}); + expect(pokemon.displayName, '雷电球'); + }); + + test('decodes the detail stats', () { + final pokemon = Pokemon.fromMap({ + ..._pokemonMap, + 'stats': {'hp': 150, 'attack': 80, 'defense': 60, 'sp_attack': 95, 'sp_defense': 70, 'speed': 100}, + }); + expect(pokemon.stats, isNotNull); + expect(pokemon.stats!.spAttack, 95); + expect(pokemon.stats!.spDefense, 70); + expect(pokemon.stats!.speed, 100); + }); + + test('computes the exp progress', () { + final pokemon = Pokemon.fromMap(_pokemonMap); + // exp 12500, current-level threshold 12000, next-level threshold 12500. + expect(pokemon.expProgress, closeTo(1.0, 0.001)); + }); + }); + + group('PokemonUserProfileMapper', () { + test('decodes the profile', () { + final profile = PokemonUserProfile.fromMap({'uid': 2234424, 'username': '猫猫祟祟', 'money': 260}); + expect(profile.uid, 2234424); + expect(profile.username, '猫猫祟祟'); + expect(profile.money, 260); + }); + }); + + group('ShopPageMapper', () { + test('decodes items with the effect type renamed from "type"', () { + final page = ShopPage.fromMap(_shopPageMap); + expect(page.items, hasLength(1)); + final item = page.items.first; + expect(item.name, '伤药'); + expect(item.price, 100); + expect(item.canBuy, isTrue); + expect(item.effect.effectType, 'heal'); + expect(item.effect.addhp, 20); + }); + }); + + group('InventoryPageMapper', () { + test('decodes the inventory', () { + final page = InventoryPage.fromMap(_inventoryPageMap); + expect(page.items, hasLength(1)); + expect(page.items.first.name, '伤药'); + expect(page.items.first.quantity, 10); + expect(page.items.first.canUse, isTrue); + }); + }); + + group('GlobalConfig', () { + test('decodes a well-typed config', () { + final config = GlobalConfig.fromJson({'is_open': true, 'version': '3.0.0-rc1', 'medical_price': 10}); + expect(config.isOpen, isTrue); + expect(config.version, '3.0.0-rc1'); + expect(config.medicalPrice, 10); + }); + + test('tolerates string and numeric values', () { + final config = GlobalConfig.fromJson({ + 'is_open': '1', + 'version': 300, + 'medical_price': '20', + 'is_enable_catch': '0', + }); + expect(config.isOpen, isTrue); + expect(config.version, '300'); + expect(config.medicalPrice, 20); + expect(config.isEnableCatch, isFalse); + }); + }); + + group('tolerant decode (missing fields)', () { + test('InventoryItem tolerates a missing nums field (production report)', () { + final item = InventoryItem.fromMap(const { + 'id': 97332, + 'type_id': 2, + 'item_type': 4, + 'name': '奇异糖果', + 'image': 'lvupitem', + 'quantity': 36, + 'type_name': '强化道具', + 'can_use': true, + }); + expect(item.name, '奇异糖果'); + expect(item.nums, 0); + expect(item.quantity, 36); + }); + + test('Pokemon tolerates a missing site field (detail response)', () { + final pokemon = Pokemon.fromMap(const { + 'id': 345908, + 'name': '火红不倒翁', + 'nickname': '火红不倒翁', + 'type_id': 554, + 'level': 5, + 'hp': 23, + 'max_hp': 23, + 'gender': 1, + 'is_shiny': false, + }); + expect(pokemon.name, '火红不倒翁'); + expect(pokemon.site, 0); + expect(pokemon.level, 5); + expect(pokemon.skills, isEmpty); + }); + }); + + group('pokemon image urls', () { + test('build the cdn and local image urls', () { + expect(pokemonImageUrl(25), 'https://img.tsdm39.com/Pokemon/pm/25.gif'); + expect(pokemonBattleBackImageUrl(25), 'https://img.tsdm39.com/Pokemon/pmb/25.gif'); + expect( + pokemonSmallImageUrl(25), + 'https://www.tsdm39.com/source/plugin/pokemon/images/spm/25.gif', + ); + expect( + pokemonItemImageUrl('hp'), + 'https://www.tsdm39.com/source/plugin/pokemon/images/item/hp.gif', + ); + }); + }); +} + +const _pokemonMap = { + 'id': 123, + 'name': '皮卡丘', + 'nickname': null, + 'type_id': 25, + 'level': 50, + 'exp': 12500, + 'exp_to_next_level': 0, + 'exp_for_current_level': 12000, + 'exp_for_next_level': 12500, + 'hp': 120, + 'max_hp': 150, + 'gender': 1, + 'is_shiny': false, + 'quality': 'S', + 'site': 1, + 'state': 1, + 'state_text': '正常', + 'state_class': 'normal', + 'affection': 100, + 'skills': [ + {'type_id': 1, 'pp': 35, 'name': '撞击', 'skill_type': '普通', 'category': '物攻', 'level': 1, 'power': 40, 'max_pp': 35}, + {'type_id': 2, 'pp': 25, 'name': '十万伏特', 'skill_type': '电', 'category': '特攻', 'level': 26, 'power': 90, 'max_pp': 15}, + {'type_id': 0, 'pp': 0, 'name': '', 'skill_type': '', 'category': '', 'level': 0, 'power': 0, 'max_pp': 0}, + {'type_id': 0, 'pp': 0, 'name': '', 'skill_type': '', 'category': '', 'level': 0, 'power': 0, 'max_pp': 0}, + ], + 'base_info': {'id': 25, 'name': '皮卡丘', 'type_1': '电', 'type_2': null, 'image': '', 'description': '老鼠宝可梦'}, +}; + +const _shopPageMap = { + 'items': [ + { + 'id': 1, + 'name': '伤药', + 'description': '回复 20 点 HP', + 'type_id': 1, + 'type_name': '回复药', + 'image': 'hp', + 'price': 100, + 'stock': 99, + 'effect': {'description': '回复 20 点 HP', 'type': 'heal', 'addhp': 20, 'addexp': 0, 'addlv': 0}, + 'can_buy': true, + }, + ], + 'total': 1, + 'page': 1, + 'per_page': 20, + 'total_pages': 1, +}; + +const _inventoryPageMap = { + 'items': [ + { + 'id': 1, + 'type_id': 1, + 'item_type': 1, + 'name': '伤药', + 'description': '回复 20 点 HP', + 'image': 'hp', + 'quantity': 10, + 'nums': 10, + 'type_name': '回复药', + 'can_use': true, + 'addhp': 20, + }, + ], + 'total': 1, + 'page': 1, + 'per_page': 20, + 'total_pages': 1, +}; diff --git a/test/regression/test_177_pokemon_adventure_battle_test.dart b/test/regression/test_177_pokemon_adventure_battle_test.dart new file mode 100644 index 00000000..20c797b3 --- /dev/null +++ b/test/regression/test_177_pokemon_adventure_battle_test.dart @@ -0,0 +1,176 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tsdm_client/features/pokemon/models/models.dart'; +import 'package:tsdm_client/features/pokemon/utils/pokemon_style.dart'; + +/// Tests of the adventure maps and battle scene models. +void main() { + group('adventureAdviceFor', () { + // The thresholds mirror the website's `get_recommendation` in `rust/game/src/pages/adventure.rs`. + test('warns when the level is far above the map', () { + expect(adventureAdviceFor(minLevel: 3, maxLevel: 8, trainerLevel: 19), AdventureAdvice.lowReward); + // The margin is ten levels, so one level less is still worth fighting. + expect(adventureAdviceFor(minLevel: 3, maxLevel: 8, trainerLevel: 18), AdventureAdvice.risky); + }); + + test('recommends a map the fighting pokemon fits', () { + expect(adventureAdviceFor(minLevel: 3, maxLevel: 8, trainerLevel: 3), AdventureAdvice.good); + expect(adventureAdviceFor(minLevel: 3, maxLevel: 8, trainerLevel: 8), AdventureAdvice.good); + }); + + test('calls a map above the level dangerous, and one without a pokemon unknown', () { + expect(adventureAdviceFor(minLevel: 3, maxLevel: 8, trainerLevel: 2), AdventureAdvice.danger); + // No pokemon yet: leaving the level out is the same as passing null. + expect(adventureAdviceFor(minLevel: 3, maxLevel: 8), AdventureAdvice.unknown); + expect(adventureAdviceFor(minLevel: 3, maxLevel: 8, trainerLevel: 0), AdventureAdvice.unknown); + }); + }); + + group('AdventureMap', () { + test('decodes a wild map', () { + final map = AdventureMap.fromMap(const { + 'id': 1, + 'name': '常磐森林', + 'area_type': 'l', + 'area_type_name': '平原', + 'region': '野外', + 'pos_x': 38, + 'pos_y': 38, + 'is_enabled': true, + 'min_level': 3, + 'max_level': 8, + 'mode': 'wild', + 'wild_pokemons': [ + {'id': 10, 'name': '绿毛虫'}, + {'id': 16, 'name': '波波'}, + ], + }); + expect(map.name, '常磐森林'); + expect(map.areaTypeName, '平原'); + expect(map.minLevel, 3); + expect(map.maxLevel, 8); + expect(map.mode, 'wild'); + expect(map.hasBoss, isFalse); + expect(map.wildPokemons, hasLength(2)); + expect(map.wildPokemons.first.name, '绿毛虫'); + }); + + test('decodes a boss map with bosses', () { + final map = AdventureMap.fromMap(const { + 'id': 2, + 'name': '冠军之路', + 'area_type': 'm', + 'area_type_name': '山谷', + 'region': '北部', + 'pos_x': 30, + 'pos_y': 25, + 'is_enabled': true, + 'min_level': 45, + 'max_level': 60, + 'mode': 'boss', + 'wild_pokemons': >[], + 'bosses': [ + {'pokemon_type_id': 149, 'pokemon_name': '快龙', 'level': 50, 'boss_multiplier': 1.5}, + ], + }); + expect(map.mode, 'boss'); + expect(map.hasBoss, isTrue); + expect(map.bosses, hasLength(1)); + expect(map.bosses!.first.pokemonName, '快龙'); + expect(map.bosses!.first.level, 50); + expect(map.bosses!.first.bossMultiplier, 1.5); + }); + }); + + group('BattleScene', () { + test('decodes an active battle', () { + final scene = BattleScene.fromMap(_activeScene); + expect(scene.isActive, isTrue); + expect(scene.battleId, 'battle_123.456'); + expect(scene.mapName, '常磐森林'); + expect(scene.turn, 1); + expect(scene.myPokemon.name, '皮卡丘'); + expect(scene.myPokemon.skills.first.name, '撞击'); + expect(scene.myPokemon.skills.first.usable, isTrue); + expect(scene.wildPokemon.isBoss, isFalse); + expect(scene.rewards, isNull); + }); + + test('decodes a victory with rewards and level-up', () { + final scene = BattleScene.fromMap({ + ..._activeScene, + 'turn': 2, + 'status': 'victory', + 'message': '野生的波波倒下了!', + 'rewards': {'exp': 120, 'money': 80}, + 'level_up': {'level_up': true, 'old_level': 10, 'new_level': 11, 'new_exp': 300}, + }); + expect(scene.isActive, isFalse); + expect(scene.status, 'victory'); + expect(scene.rewards, isNotNull); + expect(scene.rewards!.exp, 120); + expect(scene.rewards!.money, 80); + expect(scene.levelUp, isNotNull); + expect(scene.levelUp!.newLevel, 11); + }); + }); + + group('BattleItem', () { + test('decodes an HP potion and a PP restore item', () { + final potion = BattleItem.fromMap(const { + 'id': 1, + 'name': '伤药', + 'img': 'hp', + 'nums': 5, + 'item_type': 1, + 'module': '', + 'addhp': 20, + }); + expect(potion.addhp, 20); + expect(potion.itemType, 1); + + final pp = BattleItem.fromMap(const { + 'id': 30, + 'name': 'PP回复', + 'img': 'pp', + 'nums': 2, + 'item_type': 4, + 'module': 'pp10', + }); + expect(pp.addhp, isNull); + expect(pp.module, 'pp10'); + }); + }); +} + +const _activeScene = { + 'battle_id': 'battle_123.456', + 'map_id': 1, + 'map_name': '常磐森林', + 'turn': 1, + 'my_pokemon': { + 'id': 25, + 'instance_id': 1, + 'name': '皮卡丘', + 'level': 10, + 'hp': 150, + 'max_hp': 150, + 'is_shiny': false, + 'quality': 'S', + 'skills': [ + {'id': 1, 'name': '撞击', 'power': 40, 'pp': 35, 'max_pp': 35, 'skill_type': '普通', 'category': '物攻'}, + ], + }, + 'wild_pokemon': { + 'id': 16, + 'name': '波波', + 'level': 5, + 'hp': 60, + 'max_hp': 60, + 'gender': 0, + 'is_shiny': false, + 'is_boss': false, + 'boss_multiplier': 1.0, + }, + 'status': 'active', + 'message': '野生的波波出现了!', +}; diff --git a/test/regression/test_178_pokemon_skills_equipment_test.dart b/test/regression/test_178_pokemon_skills_equipment_test.dart new file mode 100644 index 00000000..af7104c8 --- /dev/null +++ b/test/regression/test_178_pokemon_skills_equipment_test.dart @@ -0,0 +1,414 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tsdm_client/exceptions/exceptions.dart'; +import 'package:tsdm_client/features/pokemon/cubit/pokemon_cubit.dart'; +import 'package:tsdm_client/features/pokemon/models/models.dart'; +import 'package:tsdm_client/features/pokemon/repository/pokemon_repository.dart'; +import 'package:tsdm_client/features/pokemon/repository/skill_order_store.dart'; +import 'package:tsdm_client/features/pokemon/utils/action_feedback.dart'; +import 'package:tsdm_client/features/pokemon/utils/pokemon_style.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; + +/// Tests of the pokemon skill, equipment and pet-shop JSON models, of the localized plugin error mapping and of the +/// status bar availability flag. +/// +/// Shapes are mirrored from `pokemon_system/api/pokemon.php` (`learnable_skills`, `equipment`) and +/// `pokemon_system/api/shop.php` (`pets`, `buy_pet`). +void main() { + group('LearnableSkillsMapper', () { + test('decodes available and unlocked skills', () { + final skills = LearnableSkills.fromMap({ + 'pokemon_id': 12, + 'pokemon_level': 20, + 'available_skills': [ + { + 'id': 7, + 'name': '电击', + 'description': '电属性的攻击', + 'type': '电', + 'category': '特攻', + 'power': 40, + 'max_pp': 30, + 'required_level': 5, + 'is_available': true, + }, + ], + 'unlocked_skills': [ + {'id': 9, 'name': '十万伏特', 'required_level': 40, 'is_available': false}, + ], + }); + expect(skills.pokemonId, 12); + expect(skills.pokemonLevel, 20); + expect(skills.availableSkills, hasLength(1)); + expect(skills.availableSkills.single.name, '电击'); + expect(skills.availableSkills.single.power, 40); + expect(skills.availableSkills.single.maxPp, 30); + expect(skills.availableSkills.single.isAvailable, isTrue); + expect(skills.unlockedSkills.single.requiredLevel, 40); + expect(skills.unlockedSkills.single.category, ''); + }); + + test('tolerates missing lists', () { + final skills = LearnableSkills.fromMap({'pokemon_id': 1}); + expect(skills.availableSkills, isEmpty); + expect(skills.unlockedSkills, isEmpty); + }); + }); + + group('EquipmentResponseMapper', () { + test('decodes slots, owned and shop items', () { + final data = EquipmentResponse.fromMap({ + 'pokemon_id': 5, + 'user_money': 900, + 'equipment_slots': [ + {'slot_index': 0, 'equipment_id': 0, 'item': null}, + { + 'slot_index': 1, + 'equipment_id': 42, + 'item': { + 'myitem_id': 42, + 'type_id': 3, + 'name': '力量腰带', + 'description': '增加攻击', + 'image': 'belt.png', + 'zbtype': 1, + 'equipment_hp': 0, + 'equipment_atk': 10, + 'equipment_def': 0, + 'equipment_spatk': 0, + 'equipment_spdef': 0, + 'equipment_sd': 0, + }, + }, + ], + 'owned_items': [ + { + 'myitem_id': 43, + 'type_id': 4, + 'name': '护腕', + 'quantity': 2, + 'equipped_count': 1, + 'available_count': 1, + 'is_equipped': false, + 'equipment_atk': 5, + }, + ], + 'shop_items': [ + {'type_id': 9, 'name': '头盔', 'price': 500, 'is_owned': false, 'can_buy': true, 'equipment_def': 8}, + ], + }); + expect(data.pokemonId, 5); + expect(data.userMoney, 900); + expect(data.equipmentSlots, hasLength(2)); + expect(data.equipmentSlots.first.item, isNull); + expect(data.equipmentSlots[1].item!.name, '力量腰带'); + expect(data.equipmentSlots[1].item!.equipmentAtk, 10); + expect(data.ownedItems.single.availableCount, 1); + expect(data.ownedItems.single.hasNoBonus, isFalse); + expect(data.shopItems.single.price, 500); + expect(data.shopItems.single.canBuy, isTrue); + }); + + test('a plain item has no bonus', () { + final item = OwnedEquipment.fromMap({'myitem_id': 1, 'type_id': 2, 'name': 'x'}); + expect(item.hasNoBonus, isTrue); + }); + }); + + group('ShopPetsPageMapper', () { + test('decodes a pet page', () { + final page = ShopPetsPage.fromMap({ + 'pets': [ + { + 'id': 25, + 'name': '皮卡丘', + 'type_1': '电', + 'type_2': null, + 'hp': 35, + 'atk': 55, + 'def': 40, + 'spatk': 50, + 'spdef': 50, + 'speed': 90, + 'price': 1000, + 'can_buy': true, + }, + ], + 'total': 3, + 'page': 1, + 'per_page': 20, + 'total_pages': 1, + }); + expect(page.pets.single.name, '皮卡丘'); + expect(page.pets.single.type2, isNull); + expect(page.pets.single.speed, 90); + expect(page.pets.single.canBuy, isTrue); + expect(page.total, 3); + expect(page.totalPages, 1); + }); + }); + + group('BuyPetResultMapper', () { + test('decodes the purchase answer', () { + final result = BuyPetResult.fromMap({ + 'message': 'Purchase successful', + 'total_cost': 1000, + 'remaining_money': 250, + 'pokemon_name': '皮卡丘', + 'pokemon_type_id': 25, + 'site': 2, + 'quantity': 1, + }); + expect(result.totalCost, 1000); + expect(result.remainingMoney, 250); + expect(result.pokemonName, '皮卡丘'); + expect(result.site, 2); + }); + }); + + group('ShopCategory', () { + test('maps categories to query types and the pet listing', () { + expect(ShopCategory.all.type, isNull); + expect(ShopCategory.all.isPet, isFalse); + expect(ShopCategory.med.type, 1); + expect(ShopCategory.equipment.type, 5); + expect(ShopCategory.pet.isPet, isTrue); + expect(ShopCategory.pet.type, isNull); + }); + }); + + group('pokemonMessageHint', () { + testWidgets("the plugin's english errors are localized", (tester) async { + await tester.runAsync(() => LocaleSettings.setLocale(AppLocale.en)); + late BuildContext context; + await tester.pumpWidget( + TranslationProvider( + child: MaterialApp( + home: Builder( + builder: (innerContext) { + context = innerContext; + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Production answers an action its build does not implement with a plain english "Invalid action" (it has no + // `toggle_status_bar`), so it must never reach the user verbatim. + expect(pokemonMessageHint(context, 'Invalid action'), t.pokemon.actionUnsupported); + // A battle the server no longer has reads as an explanation instead of that raw English. + expect(pokemonMessageHint(context, 'No active battle found'), t.pokemon.battleEnded); + expect( + pokemonMessageHint(context, 'Skill slots are full. Please forget a skill first.'), + t.pokemon.skillSlotsFull, + ); + expect(pokemonMessageHint(context, 'Skill already learned'), t.pokemon.skillAlreadyLearned); + expect( + pokemonMessageHint(context, 'Cannot forget skill with PP not full. Current PP: 3/20'), + t.pokemon.skillPpNotFull, + ); + expect(pokemonMessageHint(context, '箱子容量不足,请扩展!'), t.pokemon.boxFullHint); + // The login guard's answer (401, or a bodyless 403 on newer plugin builds) becomes the login prompt. + expect(pokemonMessageHint(context, '需要登录'), t.pokemon.loginRequired); + // The plugin's cross-site check, which the client retries after reading a fresh formhash. + expect(pokemonMessageHint(context, 'formhash 校验失败,请刷新页面后重试'), t.pokemon.formHashFailed); + // Anything else (the plugin's own chinese messages) passes through unchanged. + expect(pokemonMessageHint(context, '徽章已刷新'), '徽章已刷新'); + }); + }); + + group('pokemonStateText', () { + testWidgets('maps the state codes the plugin mirrors to localized text', (tester) async { + await tester.runAsync(() => LocaleSettings.setLocale(AppLocale.en)); + late BuildContext context; + await tester.pumpWidget( + TranslationProvider( + child: MaterialApp( + home: Builder( + builder: (innerContext) { + context = innerContext; + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // The plugin's own `state_text` is Chinese; the numbers it sends mean the same in every locale, so the views read + // those instead of what the server wrote. + expect(pokemonStateText(context, 1, '正常'), 'Normal'); + expect(pokemonStateText(context, 9, '兴奋'), 'Excited'); + expect(pokemonStateText(context, 15, '惊慌'), 'Panicked'); + expect(pokemonStateText(context, 20, '虚弱'), 'Weak'); + // A state a future plugin build adds keeps the server's own text, which beats showing nothing. + expect(pokemonStateText(context, 99, '未知状态'), '未知状态'); + }); + }); + + group('SkillOrderStore.apply', () { + List battleSkills(List ids) => [ + for (final id in ids) BattleSkill.fromMap({'id': id, 'name': 'skill-$id'}), + ]; + + test('sorts battle skills into the saved order', () { + final ordered = SkillOrderStore.apply(battleSkills([9, 4, 7]), [7, 4, 9], (s) => s.id); + expect(ordered.map((s) => s.id).toList(), [7, 4, 9]); + }); + + test('skills missing from the saved order keep their place at the end', () { + // A freshly learned skill is not in the saved order yet and must still show up, last. + final ordered = SkillOrderStore.apply(battleSkills([9, 4, 7, 11]), [7, 4, 9], (s) => s.id); + expect(ordered.map((s) => s.id).toList(), [7, 4, 9, 11]); + }); + + test('an empty saved order leaves the list untouched', () { + final ordered = SkillOrderStore.apply(battleSkills([9, 4]), const [], (s) => s.id); + expect(ordered.map((s) => s.id).toList(), [9, 4]); + }); + + test('works on the detail page skill type too', () { + final skills = [ + PokemonSkill.fromMap({'type_id': 3}), + PokemonSkill.fromMap({'type_id': 1}), + ]; + final ordered = SkillOrderStore.apply(skills, [1, 3], (s) => s.typeId); + expect(ordered.map((s) => s.typeId).toList(), [1, 3]); + }); + }); + + group('status bar state', () { + test('the profile reports the status bar state only when the plugin supports it', () { + // The menu entry is gated on this field: the production plugin has no status bar action and no such field. + expect(PokemonUserProfile.fromMap({'uid': 1}).statusBarHidden, isNull); + expect(PokemonUserProfile.fromMap({'uid': 1, 'status_bar_hidden': false}).statusBarHidden, isFalse); + expect(PokemonUserProfile.fromMap({'uid': 1, 'status_bar_hidden': true}).statusBarHidden, isTrue); + }); + }); + + group('statusBarHiddenFromBadge', () { + test('reads the hidden flag of the badge answer', () { + // The website's status bar switch is `refresh_badge` with {"hide": bool}; its answer carries the new state. + expect(statusBarHiddenFromBadge({'message': '帖子徽章已隐藏', 'hidden': true}), isTrue); + expect(statusBarHiddenFromBadge({'message': '帖子徽章已刷新', 'hidden': false}), isFalse); + // A build whose refresh_badge predates the parameter answers without the field. + expect(statusBarHiddenFromBadge({'message': '徽章已刷新'}), isNull); + }); + }); + + group('Pokemon.needsHealing', () { + Pokemon pokemon({ + int hp = 20, + int maxHp = 20, + int state = 1, + int site = 1, + List> skills = const [], + }) => Pokemon.fromMap({ + 'id': 7, + 'hp': hp, + 'max_hp': maxHp, + 'state': state, + 'site': site, + 'skills': skills, + }); + + test('does nothing for a healthy carried pet', () { + expect(pokemon().needsHealing, isFalse); + }); + + test('heals a pet that lost HP', () { + expect(pokemon(hp: 3).needsHealing, isTrue); + }); + + test('heals a pet that only spent skill PP', () { + expect(pokemon(skills: [{'type_id': 9, 'pp': 1, 'max_pp': 10}]).needsHealing, isTrue); + // An empty skill slot is not a skill and must not ask for a heal on its own. + expect(pokemon(skills: [{'type_id': 0, 'pp': 0, 'max_pp': 0}]).needsHealing, isFalse); + }); + + test('heals a pet in a negative state', () { + for (final state in Pokemon.negativeStates) { + expect(pokemon(state: state).needsHealing, isTrue, reason: 'state $state is negative'); + expect(pokemon(state: state).isNegativeState, isTrue, reason: 'state $state is negative'); + } + }); + + test('leaves the positive and neutral states alone', () { + // The plugin heal only resets its negative set: 兴奋 (8-10), 快乐 (12-14), 自恋 (16-17) and 愤怒 (18-19) are + // deliberately left as they are, so treating them as "needs healing" asks for a heal that changes nothing and + // reports the same pet as healed on every single fight. + for (final state in [8, 9, 10, 12, 13, 14, 16, 17, 18, 19]) { + expect(pokemon(state: state).isNegativeState, isFalse, reason: 'state $state is not negative'); + expect(pokemon(state: state).needsHealing, isFalse, reason: 'state $state must not ask for a heal'); + } + }); + + test('knows the negative states the plugin grew over time', () { + // 虚弱 (20-22) only joined the plugin's list later, so the set must not be the first version of it. + expect(Pokemon.negativeStates, containsAll(const [0, 2, 3, 4, 5, 6, 7, 11, 15, 20, 21, 22])); + for (final state in [20, 21, 22]) { + expect(pokemon(state: state).needsHealing, isTrue, reason: 'state $state is negative'); + } + }); + + test('leaves pokemon in the storage alone', () { + expect(pokemon(hp: 0, site: 3).needsHealing, isFalse); + }); + }); + + group('EvolutionPath', () { + test('decodes the chain the website shows', () { + final path = EvolutionPath.fromMap(const { + 'pokemon_id': 25, + 'pokemon_name': '皮卡丘', + 'pokemon_type1': '电', + 'pokemon_type2': null, + 'forward': [ + {'id': 26, 'name': '雷丘', 'type_1': '电', 'type_2': null, 'condition_display': '使用雷之石'}, + ], + 'backward': [ + {'id': 172, 'name': '皮丘', 'type_1': '电', 'type_2': null, 'condition_display': ''}, + ], + }); + + expect(path.pokemonName, '皮卡丘'); + expect(path.isEmpty, isFalse); + expect(path.forward.single.name, '雷丘'); + expect(path.forward.single.conditionDisplay, '使用雷之石'); + expect(path.backward.single.id, 172); + }); + + test('a pokemon without a chain decodes to an empty one', () { + final path = EvolutionPath.fromMap(const {'pokemon_id': 1, 'pokemon_name': 'x'}); + + expect(path.isEmpty, isTrue); + }); + }); + + group('formHashOf', () { + test('reads the formhash the plugin API asks for', () { + // The hidden input Discuz renders in its forms. + expect(formHashOf(''), '880b8aa1'); + // The inline script the plugin's own front-end reads it from. + expect(formHashOf("const formhash = '43749fab'; window.fetch = ..."), '43749fab'); + expect(formHashOf('no hash here'), isNull); + }); + }); + + group('petNeedsHealingMessage', () { + test('recognises the refusal that asks for the pokemon center', () { + expect(petNeedsHealingMessage('你的宠物已晕倒,请先前往宠物中心治疗!'), isTrue); + expect(petNeedsHealingMessage('你的宠物处于异常状态,请先前往宠物中心治疗!'), isTrue); + expect(petNeedsHealingMessage('No active battle'), isFalse); + expect(petNeedsHealingMessage(null), isFalse); + }); + + test('petNeedsHealingError only matches that failure', () { + expect(petNeedsHealingError(PokemonApiException('你的宠物已晕倒,请先前往宠物中心治疗!')), isTrue); + expect(petNeedsHealingError(PokemonApiException('No active battle')), isFalse); + expect(petNeedsHealingError('not an exception'), isFalse); + }); + }); +} diff --git a/test/regression/test_179_pokemon_dialogs_test.dart b/test/regression/test_179_pokemon_dialogs_test.dart new file mode 100644 index 00000000..b8e17ddd --- /dev/null +++ b/test/regression/test_179_pokemon_dialogs_test.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:talker_flutter/talker_flutter.dart'; +import 'package:tsdm_client/features/pokemon/utils/pokemon_dialogs.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/instance.dart'; + +/// Tests of the pokemon dialog helpers used by every purchase/confirm flow. +/// +/// They are wrapped in `RootPage` and closed through go_router; the point here is that opening and closing them leaves +/// no framework exception (the `InheritedElement.debugDeactivated` assertion reported while buying). +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() => talker = TalkerFlutter.init(settings: TalkerSettings(enabled: false))); + + Future open(WidgetTester tester, Future Function(BuildContext) action) async { + await tester.runAsync(() => LocaleSettings.setLocale(AppLocale.en)); + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (_, _) => Scaffold( + body: Builder( + builder: (context) => TextButton(onPressed: () => action(context), child: const Text('open')), + ), + ), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(TranslationProvider(child: MaterialApp.router(routerConfig: router))); + await tester.pumpAndSettle(); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + } + + testWidgets('confirm dialog confirms and closes cleanly', (tester) async { + bool? result; + await open(tester, (context) async { + result = await showPokemonConfirmDialog( + context: context, + icon: Icons.delete_outline, + title: 'release', + message: 'release it?', + confirmLabel: 'go', + dangerous: true, + ); + }); + expect(find.text('release it?'), findsOneWidget); + await tester.tap(find.text('go')); + await tester.pumpAndSettle(); + expect(result, isTrue); + expect(tester.takeException(), isNull); + }); + + testWidgets('confirm dialog cancels and closes cleanly', (tester) async { + bool? result; + await open(tester, (context) async { + result = await showPokemonConfirmDialog( + context: context, + icon: Icons.delete_outline, + title: 'release', + message: 'release it?', + confirmLabel: 'go', + ); + }); + await tester.tap(find.text(t.general.cancel)); + await tester.pumpAndSettle(); + expect(result, isFalse); + expect(tester.takeException(), isNull); + }); + + testWidgets('input dialog returns the text and closes cleanly', (tester) async { + String? result; + await open(tester, (context) async { + result = await showPokemonInputDialog( + context: context, + icon: Icons.edit, + title: 'quantity', + initialValue: '1', + confirmLabel: 'go', + hintText: 'quantity', + keyboardType: TextInputType.number, + ); + }); + await tester.enterText(find.byType(TextField), '3'); + await tester.tap(find.text('go')); + await tester.pumpAndSettle(); + expect(result, '3'); + expect(tester.takeException(), isNull); + }); + + testWidgets('input dialog cancellation returns null and closes cleanly', (tester) async { + String? result = 'unset'; + await open(tester, (context) async { + result = await showPokemonInputDialog( + context: context, + icon: Icons.edit, + title: 'quantity', + initialValue: '1', + confirmLabel: 'go', + hintText: 'quantity', + ); + }); + await tester.tap(find.text(t.general.cancel)); + await tester.pumpAndSettle(); + expect(result, isNull); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/regression/test_180_pokemon_party_healing_test.dart b/test/regression/test_180_pokemon_party_healing_test.dart new file mode 100644 index 00000000..49b48aad --- /dev/null +++ b/test/regression/test_180_pokemon_party_healing_test.dart @@ -0,0 +1,206 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:talker_flutter/talker_flutter.dart'; +import 'package:tsdm_client/features/pokemon/repository/pokemon_repository.dart'; +import 'package:tsdm_client/instance.dart'; +import 'package:tsdm_client/shared/models/models.dart'; +import 'package:tsdm_client/shared/providers/cookie_provider/cookie_provider.dart'; +import 'package:tsdm_client/shared/providers/net_client_provider/net_client_provider.dart'; +import 'package:tsdm_client/shared/providers/net_client_provider/net_error_saver.dart'; + +/// Regression test of the party heal. +/// +/// Two things have to hold: only the pokemon the plugin would actually change are asked for, and the requests run +/// together — a party that spent PP in the fight used to cost one round trip per pokemon, which is the wait the player +/// saw between two fights. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + // The client logs the requests it builds through the global talker, which the app initializes on startup. + talker = TalkerFlutter.init(settings: TalkerSettings(enabled: false)); + + /// A pokemon as the list endpoint returns it, carrying just the fields the heal decision reads. + Map pet( + int id, { + int hp = 20, + int maxHp = 20, + int state = 1, + int site = 1, + List> skills = const [], + }) => {'id': id, 'hp': hp, 'max_hp': maxHp, 'state': state, 'site': site, 'skills': skills}; + + /// Ordered list of the pokemon ids a heal was asked for. + late List healedIds; + + /// Every path the client requested, so a test can tell whether the list was fetched. + late List paths; + + /// Highest number of requests that were in flight at the same time, and the current one. + late int maxInFlight; + late int inFlight; + + /// How many clients the DI was asked to build, to prove that a repository keeps and reuses one. + late int clientBuilds; + + /// Answer the pokemon API from [party]; every heal stays busy for [healDelay] to make an overlap visible. + void useFakeApi(List> party, {Duration healDelay = Duration.zero}) { + healedIds = []; + paths = []; + maxInFlight = 0; + inFlight = 0; + clientBuilds = 0; + final dio = Dio() + ..interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) async { + inFlight++; + if (inFlight > maxInFlight) { + maxInFlight = inFlight; + } + paths.add(options.uri.toString()); + final uri = options.uri; + if (uri.queryParameters['action'] == 'heal') { + healedIds.add(uri.queryParameters['pokemon_id'] ?? ''); + if (healDelay > Duration.zero) { + await Future.delayed(healDelay); + } + } + inFlight--; + handler.resolve(Response(requestOptions: options, statusCode: 200, data: _answer(uri, party))); + }, + ), + ); + final cookie = CookieProvider(const UserLoginInfo(username: 'Alice', uid: 1), const {}); + getIt + ..registerSingleton(cookie) + ..registerSingleton(NetErrorSaver()) + ..registerFactory(() { + clientBuilds++; + return NetClientProvider.buildNoCookie(dio: dio, cookie: cookie); + }); + } + + setUp(getIt.reset); + tearDown(getIt.reset); + + test('the requests of one page load share a single formhash read', () async { + // This test has to stay first in this file: the formhash is cached per session (a static), so only the first test + // that talks to the fake forum can observe how many times the page is actually fetched. + useFakeApi([pet(1)]); + final repository = PokemonRepository(); + + // The pet centre reads several slices at once, and every one of them needs the formhash header. + await Future.wait([ + repository.loadParty(), + repository.getProfile().run(), + repository.getGlobalConfig().run(), + ]); + + expect(paths.where((path) => path.contains('id=pokemon:game')), hasLength(1)); + }); + + test('asks only for the pokemon the plugin would change', () async { + useFakeApi([ + pet(1), + pet(2, hp: 4), + pet(3, hp: 0, site: 3), + pet(4, skills: [ + {'type_id': 9, 'pp': 1, 'max_pp': 10}, + ]), + pet(5, state: 9), + ]); + + final healed = await PokemonRepository().healParty(); + + expect(healed, 2); + expect(healedIds, unorderedEquals(const ['2', '4'])); + }); + + test('heals the party in one round trip instead of one per pokemon', () async { + useFakeApi([pet(1, hp: 1), pet(2, hp: 2), pet(3, hp: 3), pet(4, hp: 4)], healDelay: const Duration(milliseconds: 40)); + + final stopwatch = Stopwatch()..start(); + final healed = await PokemonRepository().healParty(); + stopwatch.stop(); + + expect(healed, 4); + expect(healedIds, hasLength(4)); + // All four were in flight together, so the wait is one heal long instead of four. + expect(maxInFlight, 4); + expect(stopwatch.elapsedMilliseconds, lessThan(4 * 40)); + }); + + test('a repository keeps one client for all of its requests', () async { + useFakeApi([pet(1)]); + final repository = PokemonRepository(); + + await repository.loadParty(); + await repository.loadParty(); + + // A client per request means a TCP + TLS handshake per request; the repository has to keep and reuse one. + expect(clientBuilds, 1); + }); + + test('a repository renews its client after the account switched', () async { + useFakeApi([pet(1)]); + final repository = PokemonRepository(); + await repository.loadParty(); + expect(clientBuilds, 1); + + // The client carries the account's cookie jar, so the new account must not reuse the old one. + await getIt.unregister(); + getIt.registerSingleton(CookieProvider(const UserLoginInfo(username: 'Bob', uid: 2), const {})); + await repository.loadParty(); + + expect(clientBuilds, 2); + }); + + test('does nothing when the whole party is healthy', () async { + useFakeApi([pet(1), pet(2), pet(3)]); + + expect(await PokemonRepository().healParty(), 0); + expect(healedIds, isEmpty); + }); + + test('reports a plugin call that never answers instead of hanging on it', () async { + // A request the platform client parked used to hold the page in its busy state for tens of seconds (the player sees + // that as a hang), so the repository deadline has to turn it into an ordinary network failure. + final dio = Dio()..interceptors.add(InterceptorsWrapper(onRequest: (options, handler) {})); + final cookie = CookieProvider(const UserLoginInfo(username: 'Alice', uid: 1), const {}); + getIt + ..registerSingleton(cookie) + ..registerSingleton(NetErrorSaver()) + ..registerFactory(() => NetClientProvider.buildNoCookie(dio: dio, cookie: cookie)); + + final stopwatch = Stopwatch()..start(); + final result = await PokemonRepository(requestDeadline: const Duration(milliseconds: 50)).getProfile().run(); + stopwatch.stop(); + + expect(result.isLeft(), isTrue, reason: 'a call that never answers is a failure, not a pending request'); + expect(stopwatch.elapsedMilliseconds, lessThan(2000), reason: 'and it is reported long before the platform client'); + }); +} + +/// The answer the fake forum gives [uri]: the party for the list, a success envelope for a heal, and a page carrying a +/// formhash for the plugin page the client reads it from. +String _answer(Uri uri, List> party) { + final action = uri.queryParameters['action']; + if (uri.queryParameters['id'] == 'pokemon:game') { + return ''; + } + switch (action) { + case 'list': + return jsonEncode({ + 'success': true, + 'data': {'pokemons': party}, + }); + case 'heal': + return jsonEncode({ + 'success': true, + 'data': {'message': '治疗成功', 'current_hp': 20, 'max_hp': 20, 'cost': 0}, + }); + default: + return jsonEncode({'success': true, 'data': {}}); + } +} diff --git a/test/regression/test_181_pokemon_cubit_lifecycle_test.dart b/test/regression/test_181_pokemon_cubit_lifecycle_test.dart new file mode 100644 index 00000000..72fd9f5a --- /dev/null +++ b/test/regression/test_181_pokemon_cubit_lifecycle_test.dart @@ -0,0 +1,223 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:talker_flutter/talker_flutter.dart'; +import 'package:tsdm_client/features/pokemon/cubit/battle_cubit.dart'; +import 'package:tsdm_client/features/pokemon/cubit/pokemon_cubit.dart'; +import 'package:tsdm_client/features/pokemon/models/models.dart'; +import 'package:tsdm_client/instance.dart'; +import 'package:tsdm_client/shared/models/models.dart'; +import 'package:tsdm_client/shared/providers/cookie_provider/cookie_provider.dart'; +import 'package:tsdm_client/shared/providers/net_client_provider/net_client_provider.dart'; +import 'package:tsdm_client/shared/providers/net_client_provider/net_error_saver.dart'; + +/// Regression test of the pokemon centre's page cubit lifecycle. +/// +/// The page fires its load and may well be gone before the answers arrive — the forum takes seconds to answer and the +/// player taps through the menu meanwhile. The status bar switch has the same problem from the other side: its request +/// can be parked by the platform client when the app goes to the background, so the switch must not depend on the +/// answer arriving while the app is still in the foreground. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + talker = TalkerFlutter.init(settings: TalkerSettings(enabled: false)); + + /// The status bar state the fake forum keeps, so its switch and its profile agree. + late bool serverStatusBarHidden; + + /// Paths of the requests the cubit made, so a test can tell what an action touched. + late List paths; + + /// Answer every request after [delay] from a forum that keeps [serverStatusBarHidden]. + /// + /// With [turnFails] a battle turn loses the transport (no answer at all), and [recoveredScene] is what the read-back + /// after such a failure finds: this is how a scene that changed while the answer was lost is modeled. + void useFakeForum({ + Duration delay = Duration.zero, + bool turnFails = false, + Map? recoveredScene, + }) { + serverStatusBarHidden = false; + paths = []; + + String answer(Uri uri, Object? body) { + final action = uri.queryParameters['action']; + // The page the client reads the session formhash from. + if (uri.queryParameters['id'] == 'pokemon:game') { + return ''; + } + if (action == 'recover' && recoveredScene != null) { + return jsonEncode({'success': true, 'data': recoveredScene}); + } + if (action == 'refresh_badge') { + final hide = body is String ? (jsonDecode(body) as Map)['hide'] : null; + if (hide is bool) serverStatusBarHidden = hide; + return jsonEncode({ + 'success': true, + 'data': {'message': '已设置', 'hidden': serverStatusBarHidden}, + }); + } + if (action == 'profile') { + return jsonEncode({ + 'success': true, + 'data': {'status_bar_hidden': serverStatusBarHidden}, + }); + } + if (action == 'list') { + return jsonEncode({ + 'success': true, + 'data': {'pokemons': []}, + }); + } + return jsonEncode({'success': true, 'data': {}}); + } + + final dio = Dio() + ..interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) async { + paths.add(options.uri.toString()); + if (delay > Duration.zero) { + await Future.delayed(delay); + } + // A transport failure has no answer at all, which is what the client has to treat as a network problem. + if (turnFails && options.uri.queryParameters['action'] == 'turn') { + handler.reject(DioException(requestOptions: options, type: DioExceptionType.connectionTimeout)); + return; + } + handler.resolve( + Response(requestOptions: options, statusCode: 200, data: answer(options.uri, options.data)), + ); + }, + ), + ); + final cookie = CookieProvider(const UserLoginInfo(username: 'Alice', uid: 1), const {}); + getIt + ..registerSingleton(cookie) + ..registerSingleton(NetErrorSaver()) + ..registerSingleton(NetClientProvider.buildNoCookie(dio: dio, cookie: cookie)); + } + + setUp(getIt.reset); + tearDown(getIt.reset); + + test('a load that outlives its cubit does not emit into the closed one', () async { + useFakeForum(delay: const Duration(milliseconds: 50)); + final cubit = PokemonCubit(); + + final load = cubit.load(); + // The player leaves the page while the five requests are still in flight. + await cubit.close(); + + // Before the guard this completed with "Bad state: Cannot emit new states after calling close". + await expectLater(load, completes); + }); + + test('the status bar switch flips before the slow forum answers', () async { + useFakeForum(delay: const Duration(milliseconds: 200)); + final cubit = PokemonCubit(); + addTearDown(cubit.close); + await cubit.load(); + expect(cubit.state.profile?.statusBarHidden, isFalse); + + final switched = cubit.setStatusBar(hide: true); + + // The switch reads this value, and it has to show the new state while the call is still on its way. + expect(cubit.state.profile?.statusBarHidden, isTrue); + expect(serverStatusBarHidden, isFalse); + + await switched; + expect(serverStatusBarHidden, isTrue); + expect(cubit.state.profile?.statusBarHidden, isTrue); + }); + + test('an item action sends it and refreshes what it can change', () async { + useFakeForum(); + final cubit = PokemonCubit(); + addTearDown(cubit.close); + await cubit.load(); + paths.clear(); + + final result = await cubit.useItem(42, pokemonId: 7); + + expect(result.success, isTrue); + expect(paths.where((path) => path.contains('action=use_item')), hasLength(1)); + // Using an item changes the bag and, for an item aimed at one pet, the party. + expect(paths.where((path) => path.contains('action=inventory')), hasLength(1)); + expect(paths.where((path) => path.contains('action=list')), hasLength(1)); + }); + + test('the fight-again path heals the pet that just fought without reading the party again', () async { + useFakeForum(); + final cubit = BattleCubit(); + addTearDown(cubit.close); + cubit.resume( + BattleScene.fromMap(const { + 'battle_id': 'battle_1', + 'map_id': 3, + 'status': 'active', + 'my_pokemon': {'instance_id': 7, 'hp': 3, 'max_hp': 20}, + 'wild_pokemon': {'id': 25}, + }), + ); + paths.clear(); + + await cubit.fightAgain(3); + + // The scene knows that pet is hurt, so the heal needs no party list; the next battle is asked for straight after. + expect(paths.where((path) => path.contains('action=heal')), hasLength(1)); + expect(paths.where((path) => path.contains('action=list')), isEmpty); + expect(paths.where((path) => path.contains('action=start')), hasLength(1)); + }); + + test('reconcile reads the real state back', () async { + useFakeForum(); + final cubit = PokemonCubit(); + addTearDown(cubit.close); + await cubit.load(); + expect(cubit.state.profile?.statusBarHidden, isFalse); + + // The row changed without the app knowing (another device, or a switch the platform client parked). + serverStatusBarHidden = true; + + await cubit.reconcileStatusBar(); + + expect(cubit.state.profile?.statusBarHidden, isTrue); + }); + + test('an action that loses its answer reads the battle back', () async { + useFakeForum( + turnFails: true, + // The turn did land server-side: the wild pokemon is one hit from fainting, which the player's stale scene + // does not know yet. + recoveredScene: { + 'battle_id': 'battle_1', + 'map_id': 3, + 'status': 'active', + 'my_pokemon': {'instance_id': 7, 'hp': 20, 'max_hp': 20}, + 'wild_pokemon': {'id': 25, 'hp': 1, 'max_hp': 18}, + }, + ); + final cubit = BattleCubit(); + addTearDown(cubit.close); + cubit.resume( + BattleScene.fromMap(const { + 'battle_id': 'battle_1', + 'map_id': 3, + 'status': 'active', + 'my_pokemon': {'instance_id': 7, 'hp': 20, 'max_hp': 20}, + 'wild_pokemon': {'id': 25, 'hp': 18, 'max_hp': 18}, + }), + ); + + final result = await cubit.useSkill(0); + // A transport failure carries no server message, which is how the page tells it apart from a refusal. + expect(result.success, isFalse); + expect(result.message, isNull); + // The read-back runs after the failed action, so give it its turn. + await Future.delayed(const Duration(milliseconds: 20)); + + expect(paths.where((path) => path.contains('action=recover')), hasLength(1)); + expect(cubit.state.scene?.wildPokemon.hp, 1); + }); +} diff --git a/test/regression/test_182_pokemon_pure_helpers_test.dart b/test/regression/test_182_pokemon_pure_helpers_test.dart new file mode 100644 index 00000000..adf19349 --- /dev/null +++ b/test/regression/test_182_pokemon_pure_helpers_test.dart @@ -0,0 +1,96 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tsdm_client/features/pokemon/models/models.dart'; +import 'package:tsdm_client/features/pokemon/repository/adventure_cache.dart'; +import 'package:tsdm_client/features/pokemon/utils/item_merge.dart'; +import 'package:tsdm_client/features/pokemon/widgets/pokemon_count_label.dart'; +import 'package:tsdm_client/instance.dart'; +import 'package:tsdm_client/shared/models/models.dart'; +import 'package:tsdm_client/shared/providers/cookie_provider/cookie_provider.dart'; + +/// Tests of the pokemon helpers that need neither a network nor a widget: the inventory merge, the carried/box counter +/// and the adventure cache's bookkeeping of the battles the player already finished. +void main() { + // The adventure cache is a singleton that checks the account before it hands out what it remembered. + setUp(() { + getIt.registerSingleton(CookieProvider(const UserLoginInfo(username: 'Alice', uid: 1), const {})); + }); + tearDown(getIt.reset); + + InventoryItem item(int typeId, {int quantity = 1, int nums = 1}) => + InventoryItem.fromMap({'type_id': typeId, 'quantity': quantity, 'nums': nums}); + + Pokemon pokemon(int id, {int site = 1}) => Pokemon.fromMap({'id': id, 'site': site}); + + group('mergeInventoryItems', () { + test('sums the stacks of one item and keeps the order it first appeared in', () { + final merged = mergeInventoryItems([ + item(3, quantity: 2, nums: 5), + item(1), + item(3, quantity: 4, nums: 6), + ]); + + expect(merged.map((i) => i.typeId).toList(), [3, 1]); + expect(merged.first.quantity, 6); + expect(merged.first.nums, 11); + }); + + test('leaves a lone stack alone', () { + final merged = mergeInventoryItems([item(7, quantity: 3, nums: 2)]); + + expect(merged, hasLength(1)); + expect(merged.single.quantity, 3); + expect(merged.single.nums, 2); + }); + + test('an empty page stays empty', () => expect(mergeInventoryItems(const []), isEmpty)); + }); + + group('carriedAndBoxed', () { + test('counts the carried pokemon apart from the ones in the storage box', () { + final counts = carriedAndBoxed([pokemon(1), pokemon(2, site: 2), pokemon(3, site: 3), pokemon(4, site: 3)]); + + expect(counts.carried, 2); + expect(counts.boxed, 2); + }); + }); + + group('AdventureCache finished battles', () { + BattleScene scene({int mapId = 3, int wildId = 25, int instanceId = 7}) => BattleScene.fromMap({ + 'map_id': mapId, + 'wild_pokemon': {'id': wildId}, + 'my_pokemon': {'instance_id': instanceId}, + }); + + test('remembers the battle it was told about, and only that one', () { + final cache = AdventureCache(); + final battle = scene(); + expect(cache.isBattleFinished(battle), isFalse); + + cache.markBattleFinished(battle); + + expect(cache.isBattleFinished(battle), isTrue); + // The same map against another wild pokemon, or with another of the player's pokemon, is a different battle. + expect(cache.isBattleFinished(scene(wildId: 26)), isFalse); + expect(cache.isBattleFinished(scene(instanceId: 8)), isFalse); + expect(cache.isBattleFinished(scene(mapId: 4)), isFalse); + }); + + test('forgets every finished battle once the server reports none at all', () { + final cache = AdventureCache() + ..markBattleFinished(scene()) + ..clearFinishedBattles(); + + expect(cache.isBattleFinished(scene()), isFalse); + }); + + test('forgets what the previous account remembered', () async { + final cache = AdventureCache()..markBattleFinished(scene()); + + // The cache is a singleton, so switching account must not hand the previous one's battles to the new player. + await getIt.unregister(); + getIt.registerSingleton(CookieProvider(const UserLoginInfo(username: 'Bob', uid: 2), const {})); + + expect(cache.isBattleFinished(scene()), isFalse); + }); + }); +} diff --git a/test/regression/test_183_pokemon_landscape_test.dart b/test/regression/test_183_pokemon_landscape_test.dart new file mode 100644 index 00000000..17b831ef --- /dev/null +++ b/test/regression/test_183_pokemon_landscape_test.dart @@ -0,0 +1,222 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:talker_flutter/talker_flutter.dart'; +import 'package:tsdm_client/features/pokemon/models/models.dart'; +import 'package:tsdm_client/features/pokemon/repository/adventure_cache.dart'; +import 'package:tsdm_client/features/pokemon/view/adventure_page.dart'; +import 'package:tsdm_client/features/pokemon/view/battle_page.dart'; +import 'package:tsdm_client/features/pokemon/view/pokemon_page.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/instance.dart'; +import 'package:tsdm_client/shared/models/models.dart'; +import 'package:tsdm_client/shared/providers/cookie_provider/cookie_provider.dart'; +import 'package:tsdm_client/shared/providers/image_cache_provider/image_cache_provider.dart'; +import 'package:tsdm_client/shared/providers/net_client_provider/net_client_provider.dart'; +import 'package:tsdm_client/shared/providers/net_client_provider/net_error_saver.dart'; +import 'package:tsdm_client/shared/providers/providers.dart'; +import 'package:tsdm_client/shared/providers/storage_provider/models/database/database.dart'; +import 'package:tsdm_client/shared/providers/storage_provider/storage_provider.dart'; + +/// A 1x1 transparent png, so the sprites a page draws can actually decode in a widget test. +final Uint8List _pixelPng = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', +); + +/// Answers the pokemon endpoints with the smallest envelopes that decode, and any image request with a real pixel, so +/// a page can be laid out at a phone's landscape size without touching the network. +final class _PetApiAdapter implements HttpClientAdapter { + @override + Future fetch(RequestOptions options, Stream? stream, Future? cancel) async { + final uri = options.uri; + // The page the client reads the session formhash from. + if (uri.queryParameters['id'] == 'pokemon:game') { + return body(''); + } + if (uri.queryParameters['action'] == null) { + // Every plugin call carries an action; the rest are images. + return ResponseBody.fromBytes( + _pixelPng, + 200, + headers: { + Headers.contentTypeHeader: ['image/png'], + }, + ); + } + switch (uri.queryParameters['action']) { + case 'recover': + // No battle running: the adventure page reads that as "nothing to resume". + return body('{"success":false,"message":"No active battle"}', status: 404); + case 'maps': + return body( + '{"success":true,"data":{"maps":[{ ' + '"id":1,"name":"Veridian Forest","area_type":"l","area_type_name":"Plain", ' + '"min_level":3,"max_level":8,"mode":"wild","wild_pokemons":[{"id":10,"name":"Caterpie"}]}]}}', + ); + default: + return body('{"success":true,"data":{}}'); + } + } + + /// A JSON body (except for the plugin page above, which the client only scans for the formhash). + static ResponseBody body(String text, {int status = 200}) => ResponseBody.fromString( + text, + status, + headers: { + Headers.contentTypeHeader: ['application/json; charset=utf-8'], + }, + ); + + @override + void close({bool force = false}) {} +} + +void main() { + late AppDatabase db; + late Directory tmp; + + setUpAll(() async { + talker = TalkerFlutter.init(settings: TalkerSettings(enabled: false)); + await LocaleSettings.setLocale(AppLocale.en); + tmp = Directory.systemTemp.createTempSync('tsdm_pokemon_landscape_'); + // The image cache needs a directory, which a widget test has no platform for: answer it on its own channel. + TestWidgetsFlutterBinding.ensureInitialized(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + (call) async => switch (call.method) { + 'getApplicationCachePath' || 'getApplicationCacheDirectory' => '${tmp.path}/cache', + 'getApplicationSupportPath' || 'getApplicationSupportDirectory' => '${tmp.path}/support', + 'getTemporaryPath' || 'getTemporaryDirectory' => '${tmp.path}/tmp', + 'getApplicationDocumentsPath' || 'getApplicationDocumentsDirectory' => '${tmp.path}/docs', + _ => null, + }, + ); + await initCache(); + }); + + tearDownAll(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + null, + ); + tmp.deleteSync(recursive: true); + }); + + setUp(() { + db = AppDatabase(NativeDatabase.memory()); + final dio = Dio()..httpClientAdapter = _PetApiAdapter(); + final client = NetClientProvider.buildNoCookie(dio: dio, cookie: CookieProvider.buildEmpty()); + // This is a layout test: keep the app's interceptors out of the way. + dio.interceptors.clear(); + getIt + ..registerSingleton(StorageProvider(db, {}, {})) + ..registerSingleton(AdventureCache()) + ..registerSingleton(CookieProvider(const UserLoginInfo(username: 'Alice', uid: 1), const {})) + ..registerFactory(CookieProvider.buildEmpty, instanceName: ServiceKeys.empty) + ..registerSingleton(NetErrorSaver()) + ..registerSingleton(client) + ..registerSingleton( + ImageCacheProvider( + NetClientProvider.buildNoCookie(dio: dio, cookie: CookieProvider.buildEmpty()), + ), + ); + }); + + tearDown(() async { + if (getIt.isRegistered()) { + await getIt.get().dispose(); + } + await getIt.reset(); + await db.close(); + }); + + /// Lay the next page out at a landscape phone size with a cutout, the shape the logs show when the phone is turned. + void landscape(WidgetTester tester) { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(792, 368); + tester.view.padding = const FakeViewPadding(left: 44, right: 36, bottom: 24); + addTearDown(tester.view.reset); + } + + /// Bounded pumps: a page with images keeps a loading animation running, so [WidgetTester.pumpAndSettle] may never + /// return. + Future settle(WidgetTester tester) async { + for (var i = 0; i < 20; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } + } + + testWidgets('the pokemon centre lays out in landscape, on every tab', (tester) async { + landscape(tester); + await tester.pumpWidget(TranslationProvider(child: const MaterialApp(home: PokemonPage()))); + await settle(tester); + + // A render overflow is reported as an exception by the test framework. + expect(tester.takeException(), isNull); + expect(find.byType(PokemonPage), findsOneWidget); + + // Every tab has to fit the same short screen, not only the one that is opened first. + final tabCount = find.byType(Tab).evaluate().length; + expect(tabCount, 4); + for (var i = 0; i < tabCount; i++) { + await tester.tap(find.byType(Tab).at(i)); + await settle(tester); + expect(tester.takeException(), isNull, reason: 'tab $i overflows in landscape'); + } + }); + + testWidgets('the adventure page lays out in landscape', (tester) async { + landscape(tester); + await tester.pumpWidget(TranslationProvider(child: const MaterialApp(home: AdventurePage()))); + await settle(tester); + + expect(tester.takeException(), isNull); + expect(find.byType(AdventurePage), findsOneWidget); + }); + + test('a "no battle" answer is only reused for a short while', () { + final cache = getIt.get(); + expect(cache.battleCheckFresh, isFalse); + cache.markNoBattle(); + expect(cache.battleCheckFresh, isTrue); + // Starting a battle, or switching account, has to ask the server again. + cache.invalidateBattleCheck(); + expect(cache.battleCheckFresh, isFalse); + }); + + testWidgets('the battle page lays out in landscape', (tester) async { + landscape(tester); + final scene = BattleScene.fromMap(const { + 'battle_id': 'battle_1', + 'map_id': 3, + 'map_name': 'Veridian Forest', + 'status': 'active', + 'turn': 1, + 'my_pokemon': { + 'instance_id': 7, + 'id': 25, + 'name': 'Pikachu', + 'level': 12, + 'hp': 30, + 'max_hp': 40, + 'skills': [ + {'id': 1, 'name': 'Tackle', 'pp': 35, 'max_pp': 35, 'power': 40}, + ], + }, + 'wild_pokemon': {'id': 10, 'name': 'Caterpie', 'level': 5, 'hp': 12, 'max_hp': 18}, + }); + await tester.pumpWidget( + TranslationProvider( + child: MaterialApp(home: BattlePage(args: BattlePageArgs.resume(scene, mapId: 3))), + ), + ); + await settle(tester); + + expect(tester.takeException(), isNull); + }); +} From e4a4007782cbde0b84e458358d2cac4f7163bd60 Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 12:58:33 +0800 Subject: [PATCH 11/37] docs(pokemon): document the module and its server quirks What each folder holds, the conventions the module follows, the plugin behaviour that is easy to get wrong (site, defeat, the free heal, skill slots, the formhash, recover's 404), the test list and the coverage gaps. --- lib/features/pokemon/README.md | 102 +++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 lib/features/pokemon/README.md diff --git a/lib/features/pokemon/README.md b/lib/features/pokemon/README.md new file mode 100644 index 00000000..d5ee2ba1 --- /dev/null +++ b/lib/features/pokemon/README.md @@ -0,0 +1,102 @@ +# 宠物中心(pokemon) + +Discuz! X5 宠物插件(`plugin.php?id=pokemon:pokemon`)的原生客户端,功能包括:我的宠物 / 宠物中心 / 背包 / +商店、宠物详情与装备、冒险地图与战斗。 + +服务端是论坛的插件,客户端只走它的 JSON API(`endpoint=...`),不解析游戏页面。本文件记录**这一模块的结构与 +必须知道的约定**,改动前先读一遍。 + +## 目录结构 + +| 目录 | 职责 | +| --- | --- | +| `models/` | 接口 DTO(`dart_mappable` + 容错 `fromMap`)与业务判定。`pokemon.dart` 放宠物自身的规则(`needsHealing`、`negativeStates`、`isCarried/isStored/isActive`);`models.dart` 是 barrel | +| `repository/` | 所有 HTTP。`pokemon_repository.dart` 是唯一的接口入口;`adventure_cache.dart` 是跨页缓存(地图、最高等级、本会话已结束的战斗);`skill_order_store.dart` 存技能顺序(本机) | +| `cubit/` | `pokemon_cubit.dart`:宠物中心页的状态与动作;`battle_cubit.dart`:战斗状态机 | +| `view/` | 页面与 tab(`pokemon_page` 外壳 + `my_pokemon_tab`/`pokemon_center_tab`/`inventory_tab`/`shop_tab`,以及 `adventure_page`、`battle_page`、`pokemon_detail_page`、`pokemon_equipment_page`、`pokemon_storage_page`)。`battle_page.dart` 是页面与状态机,展示型的私有部件在 `battle_view.dart`(`part of`)里 | +| `widgets/` | 模块内复用的小部件(与仓库其它 feature 一致,放在 **feature 级** `widgets/`,不要放回 `view/widgets/`) | +| `utils/` | `action_feedback.dart`(接口错误 → 本地化文案)、`pokemon_style.dart`(属性/地区/状态 → 颜色与文案)、`pokemon_dialogs.dart`、`pokemon_image.dart`(图片 URL)、`item_merge.dart`(背包分页合并) | + +## 编码约定 + +- 注释与文档一律**英文 `///`**,行宽 ≤ 120,公开成员必须有文档(严格检查会拦:`dart ./scripts/check_strict_analyzing.dart`)。 +- 接口错误统一返回 `Either`(`AsyncEither`),不要在 repository 里抛异常。 +- 模型用 `dart_mappable`;改模型或 i18n 后必须重新生成:`dart run build_runner build`。 +- 所有面向用户的文案走 i18n(`context.t.pokemon.*`),三份语言文件都要加:`lib/i18n/{zh-CN,en,zh-TW}.i18n.json`。 +- 提交前:`flutter analyze lib`、`dart ./scripts/check_strict_analyzing.dart`、`flutter test`。 + +## 服务端约定与坑(重点) + +改动前务必确认下面这些,它们都是踩过的坑: + +1. **`X-Pm-Formhash`**:新版插件对 `api/index.php` 的所有请求做跨站校验,请求头必须带会话 formhash。 + 客户端在 `_ensureFormHash` 里 GET 一次游戏页解析并缓存,遇到「formhash 校验失败」会重取一次并重试一次。 + (服务端改动见插件 PR #59,已先上线、后合并。) +2. **未登录**:返回 **403 + 空体**(旧版是 401 + JSON)。`_decodeEnvelope` 把它归一化成 + `PokemonApiException('需要登录', code: 401)`;有异常对象时优先判 `error.code`,只有拿不到对象时才匹配文案。 +3. **`state` 状态码**(`plugin/api/pokemon.php: get_pokemon_state_text`): + `0` 濒危、`1` 正常、`2-4` 生病、`5-6` 饥饿、`7` 疲惫、`8-10` 兴奋、`11` 受伤、`12-14` 开心、`15` 惊慌、 + `16-17` 自恋、`18-19` 愤怒、`20-22` 虚弱。**只有 12 个负面状态会被治疗重置**,见 + `Pokemon.negativeStates`;兴奋/开心/自恋/愤怒是**故意不治**的,把它们当"需要治疗"会每次都白治一遍。 + 界面上**不要直接显示服务端的中文 `state_text`**,用 `pokemonStateText(context, state, stateText)`。 +4. **`site`**:`1` 首发、`2` 替补、`3` 仓库。服务端自己的判定是 `site < 3`,客户端统一用 + `isCarried` / `isStored` / `isActive`,不要再写 `site == 1 || site == 2`。 +5. **`status: 'defeat'` 不代表战斗结束**:插件在我方倒下但仍可用替补时保留战斗,只是回 `defeat` 让前端弹换宠。 + 所以战斗页在 `defeat` 后要 `finishDefeat`(= `user&action=heal_and_flee`,必清战斗且顺手治疗)并记住这场战斗, + 否则冒险页会用 `recover` 把它拉回来。 +6. **治疗(`user&action=heal`)免费且幂等**:无条件回满 HP 与技能 PP,并把负面状态归 `1`。 + 因此「PP 不满」也是一次有效治疗;`healParty()` 只挑 `needsHealing` 的宠物,并且**并发**发起(一只一个往返太慢)。 + **插件不会自动回血**:战斗结束既不清状态也不回 HP/PP(`clear_battle_state` 只清 npc 字段),所以网页版补满是 + 因为**网页自己请求了 heal**。客户端因此必须主动治,而且**每一种离开战斗页的方式**(结果框「确定」/返回键/逃跑) + 都要走到同一处治疗,否则队伍就留在受伤状态。`PokemonRepository` 现在会复用同一个客户端(按账号失效), + `PokemonPage` 也会在**上层页面被 pop 回来时刷新队伍**,否则治好了界面还显示旧的。 +7. **开战前不必先治疗**:`battle&action=start` 会在首发宠物不适合出战时拒绝,`BattleCubit.start` 收到该拒绝后 + 自己治疗一次再重试(`retryAfterHeal`)。这样「再战」常见情况只需一个往返。 +8. **容量**:背包与仓库**共用** `pm_usersdata.boxnum`,把宠物放进仓库不会腾出位置;错误文案要解释这一点 + (`tr.boxFullHint`)。 +9. **技能**:只有 4 个槽,`pm_myskill` 没有"槽位"列,插件的 `learn_skill` 会忽略 `slot_index`,且遗忘要求该技能 + **PP 已满**。客户端因此走「先遗忘、再学习」的两步,并把英文报错映射成中文文案。 +10. **`recover` 的 404**(`No active battle`)是正常结果(没有进行中的战斗),不要当错误弹给用户。 + 冒险页会被反复进入(从战斗页返回、切 tab),而几秒之内不可能凭空多出一场战斗,所以 `AdventureCache` + 把这次「没有战斗」的答案记住 10 秒(`markNoBattle`/`battleCheckFresh`),期间再进不重复问;一旦开战 + (`invalidateBattleCheck`)就立刻作废,避免漏掉自己刚开的那场。 +11. **图片**:大图走 CDN `https://img.tsdm39.com/Pokemon/{pm,pmb}/.gif`;小图与道具图标走论坛 + `source/plugin/pokemon/images/{spm,item}/...`(X3 时代的 `pokemon_system/images` 路径只有部分子目录还在, + 不要用)。 +12. **接口文案不稳定**:插件除登录外基本不给稳定 `code`,客户端只能按文案匹配(`pokemonMessageHint` 等)。 + 新增匹配时集中写在 `utils/action_feedback.dart`,并按「同一判断只写一处」的原则办。 + +## 测试与覆盖率 + +宠物相关回归测试(`test/regression/`):`test_176`(模型/图片 URL)、`test_177`(冒险/战斗模型)、 +`test_178`(技能/装备/商店模型、错误文案、状态码文案与 `needsHealing`)、`test_179`(弹窗)、 +`test_180`(假 Dio:客户端复用与换号重建、formhash 只读一次、`healParty` 的候选筛选与并发、从不返回的调用会超时)、 +`test_181`(页面 cubit 生命周期、状态栏开关乐观更新与校正、道具动作的请求与刷新、传输失败后重读战斗)、 +`test_182`(背包合并、随身/仓库计数、已结束战斗的记账与跨账号隔离)、 +`test_183`(横屏自查:宠物中心 4 个 tab、冒险页、战斗页在 792×368 带挖孔的横屏下不溢出;`recover` 答案的时效)。 + +需要假网络时,用 `getIt.registerSingleton(NetClientProvider.buildNoCookie(dio: …))` 装一个 +带拦截器的 `Dio`(见 `test_180`/`test_181`),不要真发请求。 + +最近一次 `flutter test --coverage` 的宠物模块行覆盖率: + +| 层 | 行覆盖率 | +| --- | --- | +| models | 97.8% | +| repository | 48.1% | +| cubit | 22.0% | +| utils | 44.9% | +| view(页面/部件) | 0.4% | +| **合计** | **22.6%**(全仓 53.5%) | + +短板仍很明显:**页面层目前只覆盖到横屏布局**(`test_183` 会真的构建页面外壳、4 个 tab、冒险页与战斗页, +但只断言不溢出),详情页、装备页与各分支逻辑仍未被覆盖。补测试优先从这里下手(页面用 widget test, +纯函数直接测)。 + +## 尚未处理 + +- 首次进入宠物中心要等 5 个并行接口(网络往返 ~2.5-3s)**加首次图片下载**,图片是一次性的,接口可以再想 + (背包/商店 tab 可以懒加载,`config` 可以做会话缓存 —— 都只省带宽,不省首屏时间)。 +- 插件侧问题(本项目已反馈):见 `tsdm-pokemon-plugin-dev/APP_INTEGRATION_ISSUES.md` + (`get_maps` N+1、容量口径、技能槽位、`defeat` 语义、请求头未文档化等)。 +- 上游相关 PR:#60(本模块提交的接口字段修复)、#59(formhash 校验)。 From aafc9094fd68219f53643598dcca3934de350be8 Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 13:52:01 +0800 Subject: [PATCH 12/37] fix(pokemon): use an if-null operator instead of comparing a nullable bool The analyzer of the flutter version CI runs flags ullable == true (use_if_null_to_convert_nulls_to_bools), and ?? false says the same thing. --- lib/features/pokemon/cubit/pokemon_cubit.dart | 2 +- lib/features/pokemon/view/battle_page.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/features/pokemon/cubit/pokemon_cubit.dart b/lib/features/pokemon/cubit/pokemon_cubit.dart index b96c2108..3eef99e5 100644 --- a/lib/features/pokemon/cubit/pokemon_cubit.dart +++ b/lib/features/pokemon/cubit/pokemon_cubit.dart @@ -299,7 +299,7 @@ class PokemonCubit extends Cubit { } final data = result.fold((_) => const {}, (value) => value); // Hiding must be confirmed by the answer; refreshing is what the request itself does. - final applied = !hide || statusBarHiddenFromBadge(data) == true; + final applied = !hide || (statusBarHiddenFromBadge(data) ?? false); if (applied) { _pendingStatusBar = null; } diff --git a/lib/features/pokemon/view/battle_page.dart b/lib/features/pokemon/view/battle_page.dart index 0dc28f58..6f283547 100644 --- a/lib/features/pokemon/view/battle_page.dart +++ b/lib/features/pokemon/view/battle_page.dart @@ -496,7 +496,7 @@ class _BattlePageState extends State with WidgetsBindingObserver { if (!mounted) return; if (!result.success) _showError(result.message); } - if (mapId != null && again == true) { + if (mapId != null && (again ?? false)) { // Healing happens inside, after the page is already marked busy, so the tap is acknowledged immediately; only // the pet that just fought is healed (the scene knows its HP and PP). await _cubit.fightAgain(mapId, bossTypeId: widget.args.bossTypeId); From 664d0a2e4c7121896215e51e91cbe5d6a1e13d0f Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 13:52:01 +0800 Subject: [PATCH 13/37] test(pokemon): wait for the battle re-read instead of assuming a fixed delay --- test/regression/test_181_pokemon_cubit_lifecycle_test.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/regression/test_181_pokemon_cubit_lifecycle_test.dart b/test/regression/test_181_pokemon_cubit_lifecycle_test.dart index 72fd9f5a..6b814385 100644 --- a/test/regression/test_181_pokemon_cubit_lifecycle_test.dart +++ b/test/regression/test_181_pokemon_cubit_lifecycle_test.dart @@ -214,8 +214,10 @@ void main() { // A transport failure carries no server message, which is how the page tells it apart from a refusal. expect(result.success, isFalse); expect(result.message, isNull); - // The read-back runs after the failed action, so give it its turn. - await Future.delayed(const Duration(milliseconds: 20)); + // The read-back runs after the failed action; wait for it instead of assuming how long it takes. + for (var i = 0; i < 200 && cubit.state.scene?.wildPokemon.hp != 1; i++) { + await Future.delayed(const Duration(milliseconds: 10)); + } expect(paths.where((path) => path.contains('action=recover')), hasLength(1)); expect(cubit.state.scene?.wildPokemon.hp, 1); From 1ccfe0ceeb278e0fea3961b9630499d4544dd1a6 Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 15:55:57 +0800 Subject: [PATCH 14/37] fix(net): never replay a json write, and only silence the 404s the app asked for OkHttp retries a request on a dropped connection, which would buy something twice or add a battle turn, so the json writes go out on the non-retrying client with a one-shot body (as postForm already does). The 404 exemption is narrowed to image requests and the plugin api: a forum page that is really gone still reports. --- .../kzs/th000/tsdm_client/HttpClient.kt | 21 ++++++++++++++++--- .../kzs/th000/tsdm_client/MainActivity.kt | 3 ++- .../net_client_provider.dart | 20 +++++++++++++++--- .../net_client_provider_android.dart | 9 ++++++-- 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/android/app/src/main/kotlin/kzs/th000/tsdm_client/HttpClient.kt b/android/app/src/main/kotlin/kzs/th000/tsdm_client/HttpClient.kt index f8500de5..af63e5a9 100644 --- a/android/app/src/main/kotlin/kzs/th000/tsdm_client/HttpClient.kt +++ b/android/app/src/main/kotlin/kzs/th000/tsdm_client/HttpClient.kt @@ -114,8 +114,23 @@ object HttpClient { } } - suspend fun postJson(url: String, headers: HashMap, body: String): Response { - val requestBody = body.toRequestBody("application/json; charset=utf-8".toMediaType()) + suspend fun postJson( + url: String, + headers: HashMap, + body: String, + singleAttempt: Boolean = false, + ): Response { + val jsonBody = body.toRequestBody("application/json; charset=utf-8".toMediaType()) + + // Same reason as postForm: a write the server may already have carried out must not be sent a second time, so a + // one-shot body (OkHttp checks isOneShot before resending) goes out on the client that does not retry. + val requestBody = if (singleAttempt) object : RequestBody() { + override fun contentType() = jsonBody.contentType() + override fun contentLength() = jsonBody.contentLength() + override fun isOneShot() = true + override fun writeTo(sink: BufferedSink) = jsonBody.writeTo(sink) + } else jsonBody + val request = Request.Builder() .url(url) .headers(Headers.headersOf(*headers.toList().flatMap { listOf(it.first, it.second) }.toTypedArray())) @@ -124,7 +139,7 @@ object HttpClient { return withContext(Dispatchers.IO) { try { - client.newCall(request).execute() + (if (singleAttempt) singleAttemptClient else client).newCall(request).execute() } catch (e: Exception) { throw e } diff --git a/android/app/src/main/kotlin/kzs/th000/tsdm_client/MainActivity.kt b/android/app/src/main/kotlin/kzs/th000/tsdm_client/MainActivity.kt index 0c476c0e..f42dc970 100644 --- a/android/app/src/main/kotlin/kzs/th000/tsdm_client/MainActivity.kt +++ b/android/app/src/main/kotlin/kzs/th000/tsdm_client/MainActivity.kt @@ -255,9 +255,10 @@ class MainActivity: FlutterActivity() { val url = call.argument("url")!! val headers = call.argument>("headers")!! val body = call.argument("body")!! + val singleAttempt = call.argument("singleAttempt") ?: false CoroutineScope(Dispatchers.IO + SupervisorJob()).launch { try { - val resp = HttpClient.postJson(url, headers, body) + val resp = HttpClient.postJson(url, headers, body, singleAttempt = singleAttempt) val statusCode = resp.code val headers = HashMap(resp.headers.toMultimap()) val body = resp.body.bytes() diff --git a/lib/shared/providers/net_client_provider/net_client_provider.dart b/lib/shared/providers/net_client_provider/net_client_provider.dart index a57aba13..684fb577 100644 --- a/lib/shared/providers/net_client_provider/net_client_provider.dart +++ b/lib/shared/providers/net_client_provider/net_client_provider.dart @@ -217,11 +217,15 @@ final class NetClientProvider with LoggerMixin { /// /// The response body is returned as a plain string and every status code is accepted so the caller can read the /// plugin's JSON envelope (including its error body) itself. [headers] are added to this request's headers. + /// + /// [singleAttempt] marks a write the server may already have carried out: no transport retry and no redirect follow + /// up, so it is never sent twice (see `postWithForm` for the same idea on form posts). AsyncEither> postJson( String path, { Object? data, Map? queryParameters, Map? headers, + bool singleAttempt = false, }) => AsyncEither.tryCatch( () async { final resp = await _dio.post( @@ -232,6 +236,8 @@ final class NetClientProvider with LoggerMixin { headers: {HttpHeaders.contentTypeHeader: Headers.jsonContentType, ...?headers}, responseType: ResponseType.plain, validateStatus: (_) => true, + followRedirects: singleAttempt ? false : null, + extra: singleAttempt ? {singleAttemptHttpRequestKey: true} : null, ), ); final status = resp.statusCode ?? 0; @@ -390,12 +396,20 @@ class _ErrorHandler extends Interceptor with LoggerMixin { handler.next(response); } + /// Whether a 404 is an answer the app asked for rather than a page it lost. + /// + /// The pet sprites sit next to the forum and half the pokemon have none, and the plugin answers 404 for `recover` + /// while no battle is running: both are normal answers, kept out of the error log and the network error banner. A + /// forum page that is really gone still reports normally. + bool _isExpectedMissing(RequestOptions options) { + final accept = options.headers[HttpHeaders.acceptHeader]?.toString() ?? ''; + return accept.contains('image/') || options.uri.queryParameters['id'] == 'pokemon:pokemon'; + } + @override void onError(DioException err, ErrorInterceptorHandler handler) { final status = err.response?.statusCode; - // A missing resource (an avatar a user never uploaded, for example) is not a network problem, so keep it out of the - // error log and out of the network error banner. - if (err.type == DioExceptionType.badResponse && status == 404) { + if (err.type == DioExceptionType.badResponse && status == 404 && _isExpectedMissing(err.requestOptions)) { debug('${err.requestOptions.uri} ${err.type}: status code: $status'); handler.next(err); return; diff --git a/lib/shared/providers/net_client_provider/net_client_provider_android.dart b/lib/shared/providers/net_client_provider/net_client_provider_android.dart index 3465f951..2f2c9b48 100644 --- a/lib/shared/providers/net_client_provider/net_client_provider_android.dart +++ b/lib/shared/providers/net_client_provider/net_client_provider_android.dart @@ -84,12 +84,14 @@ abstract class _AndroidHttpMethodChannel { required String url, required Map headers, required String body, + required bool singleAttempt, }) async { try { final resp = await _httpChannel.invokeMethod>(_methodPostJson, { 'url': url, 'headers': headers, 'body': body, + 'singleAttempt': singleAttempt, }); if (resp == null) { return null; @@ -178,8 +180,10 @@ final class KotlinHttpClient { } final contentType = headers?[HttpHeaders.contentTypeHeader]?.split(';').firstOrNull ?? ''; - if (singleAttempt && contentType != 'application/x-www-form-urlencoded') { - throw UnsupportedError('Single-attempt requests require a URL-encoded form'); + if (singleAttempt && + contentType != 'application/x-www-form-urlencoded' && + contentType != 'application/json') { + throw UnsupportedError('Single-attempt requests require a URL-encoded form or a JSON body'); } final rawResp = switch (contentType) { @@ -198,6 +202,7 @@ final class KotlinHttpClient { url: url.toString(), headers: headers ?? {}, body: body as String, + singleAttempt: singleAttempt, ), final v => throw UnsupportedError('unsupported content type: $v'), }; From 0d7b022b14a2e4602f1e4267dbc6de33d63d084a Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 15:55:57 +0800 Subject: [PATCH 15/37] fix(pokemon): mark the plugin writes single-attempt and bind the cached formhash to the account A write the server already carried out must not be sent again, and the formhash belongs to one account's session, so another account reads it instead of sending the previous one's. A refresh no longer clears a read that another request started. --- .../repository/pokemon_repository.dart | 66 +++++++++++-------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/lib/features/pokemon/repository/pokemon_repository.dart b/lib/features/pokemon/repository/pokemon_repository.dart index 65f1c60a..ce6b6939 100644 --- a/lib/features/pokemon/repository/pokemon_repository.dart +++ b/lib/features/pokemon/repository/pokemon_repository.dart @@ -59,6 +59,9 @@ final class PokemonRepository with LoggerMixin { /// Formhash of the current session, cached because it is bound to the cookie session. static String? _formHash; + /// Account the cached formhash belongs to: another account must not send the previous session's hash. + static int? _formHashUid; + /// A formhash read that is still in flight, so the requests of one page load share a single read of the page. static Future? _formHashRead; @@ -303,12 +306,15 @@ final class PokemonRepository with LoggerMixin { }, deadline: deadline); /// POST [endpoint] (optionally with a JSON [body]) and ignore the response `data`. + /// + /// The write helpers mark their calls single-attempt: a write the server already carried out must not be sent again. AsyncVoidEither _postAction(String endpoint, {Map? body}) => _send((headers) async { final resp = await _net .postJson( '$pokemonApiBase&endpoint=$endpoint', data: jsonEncode(body ?? const {}), headers: headers, + singleAttempt: true, ) .run(); return switch (resp) { @@ -327,7 +333,7 @@ final class PokemonRepository with LoggerMixin { required T Function(Map) decode, }) => _send((headers) async { final resp = await _net - .postJson('$pokemonApiBase&endpoint=$endpoint', data: jsonEncode(body), headers: headers) + .postJson('$pokemonApiBase&endpoint=$endpoint', data: jsonEncode(body), headers: headers, singleAttempt: true) .run(); return switch (resp) { Left(:final value) => left(value), @@ -371,43 +377,51 @@ final class PokemonRepository with LoggerMixin { /// A page load starts several requests at once and they all need the header, so a read that is already in flight is /// shared instead of fetching the page once per request. Future _ensureFormHash({bool refresh = false}) { - if (refresh) { + // The formhash belongs to one account's session, so a cached one is only reused for that same account. + final uid = getIt.get().userLoginInfo.uid; + if (_formHashUid != uid) { + _formHash = null; _formHashRead = null; - return _readFormHash(); + _formHashUid = uid; + } + if (!refresh) { + final cached = _formHash; + if (cached != null) return Future.value(cached); + final inFlight = _formHashRead; + if (inFlight != null) return inFlight; } - final cached = _formHash; - if (cached != null) return Future.value(cached); - return _formHashRead ??= _readFormHash(); + // A refresh starts a read of its own; an older one is left to finish without clearing this one. + final read = _readFormHash(); + _formHashRead = read; + return read.whenComplete(() { + if (identical(_formHashRead, read)) _formHashRead = null; + }); } /// Read the formhash from the plugin page and remember it; a failed read is not cached. Future _readFormHash() async { - try { - // The page read runs under the same deadline as the call that waits for it: a parked read must not hold it. - final resp = await _net - .get(_formHashPageUrl, options: _options()) - .run() - .timeout( - _requestDeadline, - onTimeout: () => left>(HttpRequestFailedException(null)), - ); - if (resp.isLeft()) return null; - final hash = formHashOf('${resp.fold((_) => '', (value) => value.data)}'); - if (hash == null) { - warning('failed to read the formhash from $_formHashPageUrl'); - return null; - } - _formHash = hash; - return hash; - } finally { - _formHashRead = null; + // The page read runs under the same deadline as the call that waits for it: a parked read must not hold it. + final resp = await _net + .get(_formHashPageUrl, options: _options()) + .run() + .timeout( + _requestDeadline, + onTimeout: () => left>(HttpRequestFailedException(null)), + ); + if (resp.isLeft()) return null; + final hash = formHashOf('${resp.fold((_) => '', (value) => value.data)}'); + if (hash == null) { + warning('failed to read the formhash from $_formHashPageUrl'); + return null; } + _formHash = hash; + return hash; } /// POST [endpoint] with a JSON [body] and return its `data` object as a raw map. AsyncEither> _postRaw(String endpoint, Map body) => _send((headers) async { final resp = await _net - .postJson('$pokemonApiBase&endpoint=$endpoint', data: jsonEncode(body), headers: headers) + .postJson('$pokemonApiBase&endpoint=$endpoint', data: jsonEncode(body), headers: headers, singleAttempt: true) .run(); return switch (resp) { Left(:final value) => left>(value), From 7f100dbf18a718737bd7cbb6778aa216ebe1ecc9 Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 15:55:58 +0800 Subject: [PATCH 16/37] fix(cache): write the image file before its cache row A row written ahead of a failed file would point at a file that is not there, so the file goes first and a failed write ends the save. --- .../image_cache_provider/image_cache_provider.dart | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/shared/providers/image_cache_provider/image_cache_provider.dart b/lib/shared/providers/image_cache_provider/image_cache_provider.dart index 1744ca5a..ab424800 100644 --- a/lib/shared/providers/image_cache_provider/image_cache_provider.dart +++ b/lib/shared/providers/image_cache_provider/image_cache_provider.dart @@ -384,23 +384,25 @@ final class ImageCacheProvider with LoggerMixin { }) async { final fileName = imageUrl.fileNameV5(); - // Update image cache info to database. - await getIt.get().updateImageCache(imageUrl, fileName: fileName); - // Make cache. final cache = getCacheFile(fileName); if (cache == null) { return; } // Android can clear this directory while the app runs (storage pressure, or the cache screen), so make sure it is - // there again — and never let a cache write fail the image being loaded: its bytes are already in hand. + // there again. A cache write is best effort — its bytes are already in hand — but a row written before the file + // would point at a file that is not there, so the file goes first and a failed write ends the save. try { await cache.parent.create(recursive: true); await cache.writeAsBytes(imageData); } on FileSystemException catch (e) { warning('could not save the image cache (${imageUrl.length} char url): ${e.message}'); + return; } + // Update image cache info to database. + await getIt.get().updateImageCache(imageUrl, fileName: fileName); + // Update other cache ref tables, if necessary. switch (usage) { case ImageUsageInfoOther(): From 148defcaf541334bc7da342a4bd63c861df701d6 Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 15:56:01 +0800 Subject: [PATCH 17/37] fix(pokemon): keep a stale battle state from surviving a newer action The read-back after a lost answer now keeps the generation it started with, is dropped while another action runs, and every action starts a new one, so its answer cannot paint over the action that followed. A scene that arrives also drops an older failure message. --- lib/features/pokemon/cubit/battle_cubit.dart | 21 +++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/lib/features/pokemon/cubit/battle_cubit.dart b/lib/features/pokemon/cubit/battle_cubit.dart index d2fa3f59..7078c00e 100644 --- a/lib/features/pokemon/cubit/battle_cubit.dart +++ b/lib/features/pokemon/cubit/battle_cubit.dart @@ -103,6 +103,9 @@ final class BattleState { final String? failureMessage; /// Copy with the given fields. + /// + /// [clearFailure] drops the message of an earlier failure: one set once would otherwise stay in the state for the rest + /// of the battle. BattleState copyWith({ BattleStatus? status, BattleScene? scene, @@ -112,6 +115,7 @@ final class BattleState { int? turn, bool? actionInProgress, String? failureMessage, + bool clearFailure = false, }) => BattleState( status: status ?? this.status, scene: scene ?? this.scene, @@ -120,7 +124,7 @@ final class BattleState { skillOrder: skillOrder ?? this.skillOrder, turn: turn ?? this.turn, actionInProgress: actionInProgress ?? this.actionInProgress, - failureMessage: failureMessage ?? this.failureMessage, + failureMessage: clearFailure ? null : failureMessage ?? this.failureMessage, ); } @@ -164,7 +168,8 @@ class BattleCubit extends Cubit with LoggerMixin { /// healing the party once and retrying, which [retryAfterHeal] guards against looping. Future start(int mapId, {int? bossTypeId, bool keepScene = false, bool retryAfterHeal = true}) async { final keep = keepScene && state.scene != null; - final generation = _generation; + // Starting a battle makes anything read back before it stale, exactly like a resume does. + final generation = ++_generation; _turn = 1; emit(state.copyWith(status: keep ? null : BattleStatus.loading, actionInProgress: keep, turn: _turn)); final result = await _repository.startBattle(mapId, bossTypeId: bossTypeId).run(); @@ -301,11 +306,14 @@ class BattleCubit extends Cubit with LoggerMixin { /// may or may not have happened); the page has to show the server's own scene instead of the one the player acted on. /// A battle the server no longer has is left alone: the page's own handling reports that on the next action. Future resync() async { + final generation = _generation; final result = await _repository.recoverBattle().run(); if (isClosed || result.isLeft()) return; final scene = result.fold((_) => null, (value) => value); if (scene == null) return; - _emitScene(scene, status: BattleStatus.success, actionInProgress: false); + // An action the player started while the battle was being read back owns the screen now. + if (state.actionInProgress) return; + _emitSceneIfCurrent(scene, generation, status: BattleStatus.success, actionInProgress: false); } /// Throw the ball [ballId]. @@ -321,7 +329,8 @@ class BattleCubit extends Cubit with LoggerMixin { /// Use item [itemId] in battle; returns the scene or asks for a skill selection. Future useItem(int itemId) async { - final generation = _generation; + // A new action owns the screen from here on, so a read-back started earlier must not paint over its answer. + final generation = ++_generation; emit(state.copyWith(actionInProgress: true)); try { final result = await _repository.useBattleItem(itemId).run(); @@ -371,7 +380,8 @@ class BattleCubit extends Cubit with LoggerMixin { /// Run a battle action that resolves a [BattleScene]. Future _runAction(AsyncEither Function() action) async { - final generation = _generation; + // A new action owns the screen from here on, so a read-back started earlier must not paint over its answer. + final generation = ++_generation; emit(state.copyWith(actionInProgress: true)); try { final result = await action().run(); @@ -408,6 +418,7 @@ class BattleCubit extends Cubit with LoggerMixin { state.copyWith( status: status, scene: scene, + clearFailure: true, wildGender: scene.isActive ? scene.wildPokemon.gender : null, wildShiny: scene.isActive ? scene.wildPokemon.isShiny : null, turn: _turn, From 7543c5a88a9129dd0f377a313f6f7c3161501643 Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 15:56:01 +0800 Subject: [PATCH 18/37] fix(pokemon): read the profile and the bag back after a write that lost its answer The server may have carried the write out after all, so the money and the bag are read again before the player tries it a second time. --- lib/features/pokemon/cubit/pokemon_cubit.dart | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/features/pokemon/cubit/pokemon_cubit.dart b/lib/features/pokemon/cubit/pokemon_cubit.dart index 3eef99e5..81664626 100644 --- a/lib/features/pokemon/cubit/pokemon_cubit.dart +++ b/lib/features/pokemon/cubit/pokemon_cubit.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:bloc/bloc.dart'; import 'package:fpdart/fpdart.dart'; import 'package:tsdm_client/exceptions/exceptions.dart'; @@ -584,17 +586,25 @@ class PokemonCubit extends Cubit { emit(state.copyWith(actionInProgress: true)); try { final result = await action().run(); - return switch (result) { - Left(:final value) => PokemonActionResult(success: false, message: _messageOf(value)), - Right() => const PokemonActionResult(success: true), - }; + final message = result.fold(_messageOf, (_) => null); + // A write that lost its answer may or may not have happened on the server, so read the money and the bag back + // before the player tries again. + if (result.isLeft() && message == null) unawaited(_reloadAfterLostAnswer()); + return PokemonActionResult(success: result.isRight(), message: message); } on Exception catch (e) { + unawaited(_reloadAfterLostAnswer()); return PokemonActionResult(success: false, message: '$e'); } finally { if (!isClosed) emit(state.copyWith(actionInProgress: false)); } } + /// Read back what a write with a lost answer may have changed: the money and the bag. + Future _reloadAfterLostAnswer() async { + await refreshProfile(); + await refreshInventory(); + } + /// Find a pokemon by id in the current state, or null. Pokemon? _findPokemon(int id) { final pokemons = state.pokemons; From e93f97b0e14c9d596b476edb8c280b0a8704582b Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 15:56:02 +0800 Subject: [PATCH 19/37] fix(ui): keep the snack bar helper comment a line comment --- lib/utils/show_toast.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/utils/show_toast.dart b/lib/utils/show_toast.dart index b737df7b..f1df0041 100644 --- a/lib/utils/show_toast.dart +++ b/lib/utils/show_toast.dart @@ -43,7 +43,8 @@ void showSnackBar({ // label and a close icon crossed that on 360dp phones (issue #4), so callers can raise the threshold. actionOverflowThreshold: actionOverflowThreshold, ); - /// Show [bar] on the app-level messenger, unless that messenger was torn down meanwhile. + + // Show [bar] on the app-level messenger, unless that messenger was torn down meanwhile. void show() { final current = snackbarKey.currentState; // A messenger whose page was just torn down trips the "deactivated widget's ancestor" assertion inside it, so skip From 3aacc09c1c6c6ab6bd3da5c688d4b51fcf6ae2ec Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 15:56:02 +0800 Subject: [PATCH 20/37] test(pokemon): cover the single-attempt writes, the per-account formhash and the overtaken read-back --- .../test_180_pokemon_party_healing_test.dart | 44 ++++++++++++++ ...test_181_pokemon_cubit_lifecycle_test.dart | 59 ++++++++++++++++++- 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/test/regression/test_180_pokemon_party_healing_test.dart b/test/regression/test_180_pokemon_party_healing_test.dart index 49b48aad..0c6ff162 100644 --- a/test/regression/test_180_pokemon_party_healing_test.dart +++ b/test/regression/test_180_pokemon_party_healing_test.dart @@ -180,6 +180,50 @@ void main() { expect(result.isLeft(), isTrue, reason: 'a call that never answers is a failure, not a pending request'); expect(stopwatch.elapsedMilliseconds, lessThan(2000), reason: 'and it is reported long before the platform client'); }); + + test('a write is sent as a single attempt, a read is not', () async { + // The android client replays a request on a connection reset unless the request is marked one-shot, and a replayed + // write (a purchase, a battle turn) would be carried out twice. + final attempts = {}; + final dio = Dio() + ..interceptors.add( + InterceptorsWrapper( + onRequest: (options, handler) { + // Key and flag as `net_client_provider_android.dart` defines them. + attempts[options.uri.queryParameters['action'] ?? ''] = options.extra['tsdm_single_attempt']; + handler.resolve( + Response(requestOptions: options, statusCode: 200, data: _answer(options.uri, [pet(1, hp: 1)])), + ); + }, + ), + ); + final cookie = CookieProvider(const UserLoginInfo(username: 'Alice', uid: 1), const {}); + getIt + ..registerSingleton(cookie) + ..registerSingleton(NetErrorSaver()) + ..registerFactory(() => NetClientProvider.buildNoCookie(dio: dio, cookie: cookie)); + + final repository = PokemonRepository(); + await repository.healParty(); + await repository.getProfile().run(); + + expect(attempts['heal'], isTrue); + expect(attempts['profile'], isNull); + }); + + test('the formhash is read again after the account switched', () async { + useFakeApi([pet(1)]); + final repository = PokemonRepository(); + await repository.loadParty(); + final reads = paths.where((path) => path.contains('id=pokemon:game')).length; + + // The hash belongs to one account's session, so the new account must not send the previous one's. + await getIt.unregister(); + getIt.registerSingleton(CookieProvider(const UserLoginInfo(username: 'Bob', uid: 2), const {})); + await repository.loadParty(); + + expect(paths.where((path) => path.contains('id=pokemon:game')).length, reads + 1); + }); } /// The answer the fake forum gives [uri]: the party for the list, a success envelope for a heal, and a page carrying a diff --git a/test/regression/test_181_pokemon_cubit_lifecycle_test.dart b/test/regression/test_181_pokemon_cubit_lifecycle_test.dart index 6b814385..0dc3e6f6 100644 --- a/test/regression/test_181_pokemon_cubit_lifecycle_test.dart +++ b/test/regression/test_181_pokemon_cubit_lifecycle_test.dart @@ -30,12 +30,15 @@ void main() { /// Answer every request after [delay] from a forum that keeps [serverStatusBarHidden]. /// - /// With [turnFails] a battle turn loses the transport (no answer at all), and [recoveredScene] is what the read-back - /// after such a failure finds: this is how a scene that changed while the answer was lost is modeled. + /// With [turnFails] a battle turn loses the transport (no answer at all); [recoverDelay] holds the read-back after + /// such a failure open; [recoveredScene] is what that read-back finds and [startedScene] what the next battle start + /// answers, so a scene that changed while an answer was lost can be told apart from the one that was read back. void useFakeForum({ Duration delay = Duration.zero, bool turnFails = false, + Duration recoverDelay = Duration.zero, Map? recoveredScene, + Map? startedScene, }) { serverStatusBarHidden = false; paths = []; @@ -49,6 +52,9 @@ void main() { if (action == 'recover' && recoveredScene != null) { return jsonEncode({'success': true, 'data': recoveredScene}); } + if (action == 'start' && startedScene != null) { + return jsonEncode({'success': true, 'data': startedScene}); + } if (action == 'refresh_badge') { final hide = body is String ? (jsonDecode(body) as Map)['hide'] : null; if (hide is bool) serverStatusBarHidden = hide; @@ -80,6 +86,9 @@ void main() { if (delay > Duration.zero) { await Future.delayed(delay); } + if (options.uri.queryParameters['action'] == 'recover' && recoverDelay > Duration.zero) { + await Future.delayed(recoverDelay); + } // A transport failure has no answer at all, which is what the client has to treat as a network problem. if (turnFails && options.uri.queryParameters['action'] == 'turn') { handler.reject(DioException(requestOptions: options, type: DioExceptionType.connectionTimeout)); @@ -222,4 +231,50 @@ void main() { expect(paths.where((path) => path.contains('action=recover')), hasLength(1)); expect(cubit.state.scene?.wildPokemon.hp, 1); }); + + test('a battle read-back a newer action overtook is dropped', () async { + const started = { + 'battle_id': 'battle_2', + 'map_id': 3, + 'status': 'active', + 'my_pokemon': {'instance_id': 7, 'hp': 20, 'max_hp': 20}, + 'wild_pokemon': {'id': 25, 'hp': 9, 'max_hp': 18}, + }; + useFakeForum( + turnFails: true, + recoverDelay: const Duration(milliseconds: 150), + // What the read-back finds: the turn did land server-side. + recoveredScene: { + 'battle_id': 'battle_1', + 'map_id': 3, + 'status': 'active', + 'my_pokemon': {'instance_id': 7, 'hp': 20, 'max_hp': 20}, + 'wild_pokemon': {'id': 25, 'hp': 1, 'max_hp': 18}, + }, + startedScene: started, + ); + final cubit = BattleCubit(); + addTearDown(cubit.close); + cubit.resume( + BattleScene.fromMap(const { + 'battle_id': 'battle_1', + 'map_id': 3, + 'status': 'active', + 'my_pokemon': {'instance_id': 7, 'hp': 20, 'max_hp': 20}, + 'wild_pokemon': {'id': 25, 'hp': 18, 'max_hp': 18}, + }), + ); + + final failed = await cubit.useSkill(0); + expect(failed.success, isFalse); + + // The player starts the next battle while the read-back is still in flight. + await cubit.start(3); + expect(cubit.state.scene?.battleId, 'battle_2'); + + // It lands after the newer scene and must not paint over it. + await Future.delayed(const Duration(milliseconds: 400)); + expect(cubit.state.scene?.battleId, 'battle_2'); + expect(cubit.state.scene?.wildPokemon.hp, 9); + }); } From d704078ab6bbfcc80210f36188c587ef4c6d6e59 Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 16:14:09 +0800 Subject: [PATCH 21/37] fix(pokemon): catch decode errors, and forget a battle the client starts again dart_mappable throws a TypeError (an Error, not an Exception) on an unexpected field type, so the cubits' on Exception handlers let it escape; they catch Object now, like the repository does. Starting a battle also drops its finished-battle key, which repeats on the same map against the same species with the same own pokemon, so a fight the client just began is not taken for one that already ended. --- lib/features/pokemon/cubit/battle_cubit.dart | 13 +++++++++---- lib/features/pokemon/cubit/pokemon_cubit.dart | 8 ++++---- .../pokemon/repository/adventure_cache.dart | 7 +++++++ .../test_181_pokemon_cubit_lifecycle_test.dart | 2 ++ .../test_182_pokemon_pure_helpers_test.dart | 11 +++++++++++ 5 files changed, 33 insertions(+), 8 deletions(-) diff --git a/lib/features/pokemon/cubit/battle_cubit.dart b/lib/features/pokemon/cubit/battle_cubit.dart index 7078c00e..e633507d 100644 --- a/lib/features/pokemon/cubit/battle_cubit.dart +++ b/lib/features/pokemon/cubit/battle_cubit.dart @@ -4,10 +4,12 @@ import 'package:bloc/bloc.dart'; import 'package:fpdart/fpdart.dart'; import 'package:tsdm_client/exceptions/exceptions.dart'; import 'package:tsdm_client/features/pokemon/models/models.dart'; +import 'package:tsdm_client/features/pokemon/repository/adventure_cache.dart'; import 'package:tsdm_client/features/pokemon/repository/pokemon_repository.dart'; import 'package:tsdm_client/features/pokemon/repository/skill_order_store.dart'; import 'package:tsdm_client/features/pokemon/utils/action_feedback.dart'; import 'package:tsdm_client/features/pokemon/utils/item_merge.dart'; +import 'package:tsdm_client/instance.dart'; import 'package:tsdm_client/utils/logger.dart'; /// Loading status of a battle. @@ -192,6 +194,9 @@ class BattleCubit extends Cubit with LoggerMixin { ), ); case Right(:final value): + // The client started this battle itself, so it is not one the adventure page already saw end: on the same map + // against the same species with the same own pokemon the key repeats. + getIt.get().forgetFinishedBattle(value); _emitSceneIfCurrent(value, generation, status: BattleStatus.success, actionInProgress: false); } } @@ -221,7 +226,7 @@ class BattleCubit extends Cubit with LoggerMixin { (e) => BattleActionResult(success: false, message: _messageOf(e)), (heal) => BattleActionResult(success: true, message: heal.message), ); - } on Exception catch (e) { + } on Object catch (e) { return BattleActionResult(success: false, message: '$e'); } finally { if (!isClosed) emit(state.copyWith(actionInProgress: false)); @@ -348,12 +353,12 @@ class BattleCubit extends Cubit with LoggerMixin { if (scene.isActive) _turn++; if (!isClosed) _emitSceneIfCurrent(scene, generation); return BattleUseItemResult(scene: scene); - } on Exception { + } on Object { return const BattleUseItemResult(message: 'unexpected battle item response'); } }, ); - } on Exception catch (e) { + } on Object catch (e) { return BattleUseItemResult(message: '$e'); } finally { if (!isClosed) emit(state.copyWith(actionInProgress: false)); @@ -399,7 +404,7 @@ class BattleCubit extends Cubit with LoggerMixin { return const BattleActionResult(success: true); }, ); - } on Exception catch (e) { + } on Object catch (e) { return BattleActionResult(success: false, message: '$e'); } finally { if (!isClosed) emit(state.copyWith(actionInProgress: false)); diff --git a/lib/features/pokemon/cubit/pokemon_cubit.dart b/lib/features/pokemon/cubit/pokemon_cubit.dart index 81664626..7d9fc211 100644 --- a/lib/features/pokemon/cubit/pokemon_cubit.dart +++ b/lib/features/pokemon/cubit/pokemon_cubit.dart @@ -531,7 +531,7 @@ class PokemonCubit extends Cubit { }; if (!actionResult.success) await refreshPokemons(); return actionResult; - } on Exception catch (e) { + } on Object catch (e) { return PokemonActionResult(success: false, message: '$e'); } finally { if (!isClosed) emit(state.copyWith(actionInProgress: false)); @@ -556,7 +556,7 @@ class PokemonCubit extends Cubit { }; if (actionResult.success) await Future.wait([refreshShop(), refreshProfile()]); return actionResult; - } on Exception catch (e) { + } on Object catch (e) { return PokemonActionResult(success: false, message: '$e'); } finally { if (!isClosed) emit(state.copyWith(actionInProgress: false)); @@ -574,7 +574,7 @@ class PokemonCubit extends Cubit { }; if (actionResult.success) await Future.wait([refreshShop(), refreshProfile(), refreshPokemons()]); return actionResult; - } on Exception catch (e) { + } on Object catch (e) { return PokemonActionResult(success: false, message: '$e'); } finally { if (!isClosed) emit(state.copyWith(actionInProgress: false)); @@ -591,7 +591,7 @@ class PokemonCubit extends Cubit { // before the player tries again. if (result.isLeft() && message == null) unawaited(_reloadAfterLostAnswer()); return PokemonActionResult(success: result.isRight(), message: message); - } on Exception catch (e) { + } on Object catch (e) { unawaited(_reloadAfterLostAnswer()); return PokemonActionResult(success: false, message: '$e'); } finally { diff --git a/lib/features/pokemon/repository/adventure_cache.dart b/lib/features/pokemon/repository/adventure_cache.dart index 3718bd11..94a390f2 100644 --- a/lib/features/pokemon/repository/adventure_cache.dart +++ b/lib/features/pokemon/repository/adventure_cache.dart @@ -128,6 +128,13 @@ final class AdventureCache { _finishedBattles.add(battleKey(scene)); } + /// Forget that [scene]'s battle is over: the client just started this battle itself, and on the same map for the + /// same species with the same own pokemon the key repeats, so that new fight is not the finished one. + void forgetFinishedBattle(BattleScene scene) { + _forgetOtherAccount(); + _finishedBattles.remove(battleKey(scene)); + } + /// Whether [scene]'s battle is one the player already finished. bool isBattleFinished(BattleScene scene) { _forgetOtherAccount(); diff --git a/test/regression/test_181_pokemon_cubit_lifecycle_test.dart b/test/regression/test_181_pokemon_cubit_lifecycle_test.dart index 0dc3e6f6..865f3018 100644 --- a/test/regression/test_181_pokemon_cubit_lifecycle_test.dart +++ b/test/regression/test_181_pokemon_cubit_lifecycle_test.dart @@ -6,6 +6,7 @@ import 'package:talker_flutter/talker_flutter.dart'; import 'package:tsdm_client/features/pokemon/cubit/battle_cubit.dart'; import 'package:tsdm_client/features/pokemon/cubit/pokemon_cubit.dart'; import 'package:tsdm_client/features/pokemon/models/models.dart'; +import 'package:tsdm_client/features/pokemon/repository/adventure_cache.dart'; import 'package:tsdm_client/instance.dart'; import 'package:tsdm_client/shared/models/models.dart'; import 'package:tsdm_client/shared/providers/cookie_provider/cookie_provider.dart'; @@ -103,6 +104,7 @@ void main() { final cookie = CookieProvider(const UserLoginInfo(username: 'Alice', uid: 1), const {}); getIt ..registerSingleton(cookie) + ..registerSingleton(AdventureCache()) ..registerSingleton(NetErrorSaver()) ..registerSingleton(NetClientProvider.buildNoCookie(dio: dio, cookie: cookie)); } diff --git a/test/regression/test_182_pokemon_pure_helpers_test.dart b/test/regression/test_182_pokemon_pure_helpers_test.dart index adf19349..adddaf7b 100644 --- a/test/regression/test_182_pokemon_pure_helpers_test.dart +++ b/test/regression/test_182_pokemon_pure_helpers_test.dart @@ -83,6 +83,17 @@ void main() { expect(cache.isBattleFinished(scene()), isFalse); }); + test('forgets the battle the client starts again instead of taking it for the finished one', () { + // The key is map + wild species + own pokemon, which repeats while the player keeps fighting on one map, so a new + // fight must not be read as the one that already ended. + final cache = AdventureCache()..markBattleFinished(scene()); + expect(cache.isBattleFinished(scene()), isTrue); + + cache.forgetFinishedBattle(scene()); + + expect(cache.isBattleFinished(scene()), isFalse); + }); + test('forgets what the previous account remembered', () async { final cache = AdventureCache()..markBattleFinished(scene()); From 177e5f45e34b24cd5483af1e9f7dd10426cefd6a Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 16:36:45 +0800 Subject: [PATCH 22/37] fix(net): keep the wider http timeouts for the plugin api and images only The widened timeouts were global, so a forum page under a weak network took 30s to report a failure instead of 10s. The plugin api (its calls carry X-Pm-Formhash) and the image cache (Accept: image/*) now use a client of their own, and everything else keeps the OkHttp defaults. --- .../kzs/th000/tsdm_client/HttpClient.kt | 46 +++++++++++++------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/android/app/src/main/kotlin/kzs/th000/tsdm_client/HttpClient.kt b/android/app/src/main/kotlin/kzs/th000/tsdm_client/HttpClient.kt index af63e5a9..a77a97df 100644 --- a/android/app/src/main/kotlin/kzs/th000/tsdm_client/HttpClient.kt +++ b/android/app/src/main/kotlin/kzs/th000/tsdm_client/HttpClient.kt @@ -16,11 +16,14 @@ import java.net.ProxySelector import java.util.concurrent.TimeUnit object HttpClient { - // The forum and its CDN answer slowly under load (the 10s OkHttp defaults timed out the pokemon heal and the - // sprite downloads on slower devices), so relax the timeouts. Write stays generous for uploads. - private val client by lazy { - OkHttpClient.Builder() - .proxySelector(ProxySelector.getDefault()) + // The forum answers quickly, so its requests keep the OkHttp defaults (10s): a page under a weak network must fail + // as fast as it always did. + private val client by lazy { OkHttpClient.Builder().proxySelector(ProxySelector.getDefault()).build() } + + // The plugin api and the image cdn answer slowly under load (the 10s defaults timed out the pokemon heal and the + // sprite downloads on slower devices), so the requests that go there get more time. + private val relaxedClient by lazy { + client.newBuilder() .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(60, TimeUnit.SECONDS) @@ -28,12 +31,29 @@ object HttpClient { } // Share connections and dispatchers, but never replay a non-idempotent transaction. - private val singleAttemptClient by lazy { - client.newBuilder() - .retryOnConnectionFailure(false) - .followRedirects(false) - .followSslRedirects(false) - .build() + private val singleAttemptClient by lazy { noReplay(client) } + private val relaxedSingleAttemptClient by lazy { noReplay(relaxedClient) } + + private fun noReplay(base: OkHttpClient) = base.newBuilder() + .retryOnConnectionFailure(false) + .followRedirects(false) + .followSslRedirects(false) + .build() + + private fun pick(relaxed: Boolean, singleAttempt: Boolean) = when { + relaxed && singleAttempt -> relaxedSingleAttemptClient + relaxed -> relaxedClient + singleAttempt -> singleAttemptClient + else -> client + } + + // The plugin's own header marks its api calls, and the image cache accepts images; both are the endpoints that + // need more time. Keeping the choice here avoids a second method channel flag for the same meaning. + private fun needsMoreTime(headers: Map): Boolean { + val wantsImage = headers.entries.any { (key, value) -> + key.equals("Accept", ignoreCase = true) && value.startsWith("image/") + } + return wantsImage || headers.keys.any { it.equals("X-Pm-Formhash", ignoreCase = true) } } suspend fun get(url: String, headers: HashMap): Response { @@ -45,7 +65,7 @@ object HttpClient { return withContext(Dispatchers.IO) { try { - client.newCall(request).execute() + pick(needsMoreTime(headers), false).newCall(request).execute() } catch (e: Exception) { throw e } @@ -139,7 +159,7 @@ object HttpClient { return withContext(Dispatchers.IO) { try { - (if (singleAttempt) singleAttemptClient else client).newCall(request).execute() + pick(needsMoreTime(headers), singleAttempt).newCall(request).execute() } catch (e: Exception) { throw e } From 95562472bfd3697112089d328c13855581246fdb Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 16:54:12 +0800 Subject: [PATCH 23/37] fix(pokemon): end a lost battle the end scene no longer names The end-of-battle answer carries no battle id and may leave the pokemon id at 0, so heal_and_flee and the flee fallback were both sent with nothing and the battle stayed on the server. The last scene that named its battle is kept, and the end scene fills in what it is missing; without either, the party is healed and the page still remembers the battle as over. --- lib/features/pokemon/cubit/battle_cubit.dart | 52 ++++++++++++++----- ...test_181_pokemon_cubit_lifecycle_test.dart | 41 +++++++++++++++ 2 files changed, 80 insertions(+), 13 deletions(-) diff --git a/lib/features/pokemon/cubit/battle_cubit.dart b/lib/features/pokemon/cubit/battle_cubit.dart index e633507d..47d65254 100644 --- a/lib/features/pokemon/cubit/battle_cubit.dart +++ b/lib/features/pokemon/cubit/battle_cubit.dart @@ -160,6 +160,12 @@ class BattleCubit extends Cubit with LoggerMixin { /// Bumped when a resume invalidates the answers of requests that were still in flight. int _generation = 0; + /// The last scene the server reported while the battle was still running. + /// + /// The end-of-battle answer carries no battle id, and may leave the pokemon's id at 0, but ending the battle on the + /// server needs both, so the running values are kept (as the gender and the shiny state are). + BattleScene? _runningScene; + /// Round counter of the current battle; the plugin itself does not count turns. int _turn = 1; @@ -432,6 +438,9 @@ class BattleCubit extends Cubit with LoggerMixin { ); // The device-local skill order belongs to one pokemon, so re-read it whenever the battling one changes. if (otherPokemon) unawaited(_loadSkillOrder(scene.myPokemon.instanceId)); + // Keep the last scene that named its battle: ending a battle on the server needs a battle id, and the end-of-battle + // answer (and a scene without one) cannot provide it. + if (scene.battleId.isNotEmpty) _runningScene = scene; } /// Emit [scene] unless the app resumed while the answer was in flight, which made that answer stale. @@ -460,22 +469,39 @@ class BattleCubit extends Cubit with LoggerMixin { /// finished battle and the player had to flee by hand. `heal_and_flee` clears the battle unconditionally and heals /// the pokemon for free, so it goes first; the randomized `flee` is the fallback. Future finishDefeat(BattleScene scene) async { - final healed = await _repository.healAndFlee(scene.myPokemon.instanceId).run(); - if (isClosed) return const BattleActionResult(success: false); - if (healed.isRight()) { - return healed.fold( - (_) => const BattleActionResult(success: true), - (data) => BattleActionResult(success: true, message: data.message), - ); + // The end-of-battle answer carries no battle id and may leave the pokemon's id at 0, so the last running scene fills + // them in: without a usable id nothing could end the battle the server still keeps. + final running = _runningScene; + final instanceId = scene.myPokemon.instanceId > 0 ? scene.myPokemon.instanceId : running?.myPokemon.instanceId ?? 0; + final battleId = scene.battleId.isNotEmpty ? scene.battleId : running?.battleId ?? ''; + + if (instanceId > 0) { + final healed = await _repository.healAndFlee(instanceId).run(); + if (isClosed) return const BattleActionResult(success: false); + if (healed.isRight()) { + return healed.fold( + (_) => const BattleActionResult(success: true), + (data) => BattleActionResult(success: true, message: data.message), + ); + } + // The server had already cleared the battle (no usable backup, or another client ended it): heal directly, so the + // pet is restored even when there is nothing left to end. + final healError = healed.fold((e) => e, (_) => null); + if (battleAlreadyOverError(healError)) { + return healPet(instanceId); + } } - // The server had already cleared the battle (no usable backup, or another client ended it): heal directly, so the - // pet is restored even when there is nothing left to end. - final healError = healed.fold((e) => e, (_) => null); - if (battleAlreadyOverError(healError)) { - return healPet(scene.myPokemon.instanceId); + + if (battleId.isEmpty) { + // Nothing left to end the battle with: heal what the party needs, and the page remembers the battle as over. + await healParty(); + return const BattleActionResult(success: true); } - final fled = await _repository.flee(scene.battleId).run(); + + final fled = await _repository.flee(battleId).run(); if (isClosed) return const BattleActionResult(success: false); + // The pet could not be named, so its own heal was skipped: heal whatever the party needs once the battle is over. + if (instanceId <= 0) await healParty(); return fled.fold( (e) => BattleActionResult(success: false, message: _messageOf(e)), (fledScene) => BattleActionResult(success: true, message: fledScene.message), diff --git a/test/regression/test_181_pokemon_cubit_lifecycle_test.dart b/test/regression/test_181_pokemon_cubit_lifecycle_test.dart index 865f3018..672fd88c 100644 --- a/test/regression/test_181_pokemon_cubit_lifecycle_test.dart +++ b/test/regression/test_181_pokemon_cubit_lifecycle_test.dart @@ -56,6 +56,16 @@ void main() { if (action == 'start' && startedScene != null) { return jsonEncode({'success': true, 'data': startedScene}); } + if (action == 'flee') { + // A flee needs the battle id, which the end-of-battle answer lacks: only a caller that kept it can end the fight. + final id = body is String ? (jsonDecode(body) as Map)['battle_id'] : null; + final ended = id is String && id.isNotEmpty; + return jsonEncode({ + 'success': ended, + 'data': {'message': '已逃跑'}, + if (!ended) 'error': '不存在的战斗', + }); + } if (action == 'refresh_badge') { final hide = body is String ? (jsonDecode(body) as Map)['hide'] : null; if (hide is bool) serverStatusBarHidden = hide; @@ -279,4 +289,35 @@ void main() { expect(cubit.state.scene?.battleId, 'battle_2'); expect(cubit.state.scene?.wildPokemon.hp, 9); }); + + test('ends a lost battle the end scene no longer names', () async { + useFakeForum(); + final cubit = BattleCubit(); + addTearDown(cubit.close); + // The running scene carries the battle id (the pet itself may already be gone from the answer)... + cubit.resume( + BattleScene.fromMap(const { + 'battle_id': 'battle_1', + 'map_id': 3, + 'status': 'active', + 'my_pokemon': {'instance_id': 0, 'hp': 20, 'max_hp': 20}, + 'wild_pokemon': {'id': 25, 'hp': 1, 'max_hp': 18}, + }), + ); + + // ...while the end-of-battle answer has neither, so `heal_and_flee` and `flee` would both be sent with nothing. + final result = await cubit.finishDefeat( + BattleScene.fromMap(const { + 'battle_id': '', + 'map_id': 3, + 'status': 'defeat', + 'my_pokemon': {'instance_id': 0, 'hp': 0, 'max_hp': 20}, + 'wild_pokemon': {'id': 25, 'hp': 1, 'max_hp': 18}, + }), + ); + + expect(result.success, isTrue, reason: 'the battle has to be ended with the id the running scene kept'); + expect(paths.where((path) => path.contains('action=flee')), hasLength(1)); + expect(paths.where((path) => path.contains('heal_and_flee')), isEmpty); + }); } From 515b2dd35935a3d71894eee3a7a61279658768d0 Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 16:54:13 +0800 Subject: [PATCH 24/37] fix(pokemon): give a write longer than the transport timeout before reporting it The 20s deadline is shorter than the android client's 30s read timeout, so a write that was still in flight was reported as a failure although the server may well have carried it out (a second tap would buy again). Writes get 35s now, which lets the transport report its own, accurate failure. --- .../pokemon/repository/pokemon_repository.dart | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/features/pokemon/repository/pokemon_repository.dart b/lib/features/pokemon/repository/pokemon_repository.dart index ce6b6939..a9e15e90 100644 --- a/lib/features/pokemon/repository/pokemon_repository.dart +++ b/lib/features/pokemon/repository/pokemon_repository.dart @@ -47,6 +47,12 @@ final class PokemonRepository with LoggerMixin { /// Deadline of the map list: on the plugin side it runs a sub-query for every map, so it is the one slow endpoint. static const _mapsDeadline = Duration(seconds: 60); + /// Deadline of a write. + /// + /// It has to outlast the transport's own read timeout (30s in the android client), or a write that was still in flight + /// would be reported as a failure although the server may well have carried it out. + static const _writeDeadline = Duration(seconds: 35); + /// Header the plugin's API expects for its cross-site request check. /// /// The plugin answers `formhash 校验失败,请刷新页面后重试` when a request does not carry the session's formhash, so it @@ -324,7 +330,7 @@ final class PokemonRepository with LoggerMixin { (_) => right(null), ), }; - }); + }, deadline: _writeDeadline); /// POST [endpoint] with a JSON [body] and decode its `data` object with [decode]. AsyncEither _postData( @@ -339,7 +345,7 @@ final class PokemonRepository with LoggerMixin { Left(:final value) => left(value), Right(:final value) => _decodeEnvelope(value).fold((e) => left(e), (data) => _decode(data, decode, value)), }; - }); + }, deadline: _writeDeadline); /// Send [request] with the session formhash attached, reading it again and retrying once when the server calls it /// stale (the plugin ties it to the session, which the forum may renew). @@ -427,7 +433,7 @@ final class PokemonRepository with LoggerMixin { Left(:final value) => left>(value), Right(:final value) => _decodeEnvelope(value), }; - }); + }, deadline: _writeDeadline); /// GET [endpoint] and return its `data` object as a raw map. AsyncEither> _getRaw(String endpoint) => _send((headers) async { From 4c07067bd4517163db699195d6a56a66ab4cb6d7 Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 17:13:59 +0800 Subject: [PATCH 25/37] fix(net): give the plugin api its wider timeouts even without its header The widened client was picked from the X-Pm-Formhash header, which the read that fetches the formhash itself does not carry - and a request made without one (a formhash that could not be read) fell back to the 10s client, exactly the case the wider timeouts were for. The plugin url is checked first now. --- .../main/kotlin/kzs/th000/tsdm_client/HttpClient.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/kotlin/kzs/th000/tsdm_client/HttpClient.kt b/android/app/src/main/kotlin/kzs/th000/tsdm_client/HttpClient.kt index a77a97df..f9496734 100644 --- a/android/app/src/main/kotlin/kzs/th000/tsdm_client/HttpClient.kt +++ b/android/app/src/main/kotlin/kzs/th000/tsdm_client/HttpClient.kt @@ -47,9 +47,11 @@ object HttpClient { else -> client } - // The plugin's own header marks its api calls, and the image cache accepts images; both are the endpoints that - // need more time. Keeping the choice here avoids a second method channel flag for the same meaning. - private fun needsMoreTime(headers: Map): Boolean { + // The plugin's own pages and api answer slowly, and so does the image cdn: those requests get the wider timeouts. + // The url matters most: the read that fetches the session formhash carries no plugin header yet, and a request made + // without one (a formhash that could not be read) has to keep the wider timeouts too. + private fun needsMoreTime(url: String, headers: Map): Boolean { + if (url.contains("id=pokemon")) return true val wantsImage = headers.entries.any { (key, value) -> key.equals("Accept", ignoreCase = true) && value.startsWith("image/") } @@ -65,7 +67,7 @@ object HttpClient { return withContext(Dispatchers.IO) { try { - pick(needsMoreTime(headers), false).newCall(request).execute() + pick(needsMoreTime(url, headers), false).newCall(request).execute() } catch (e: Exception) { throw e } @@ -159,7 +161,7 @@ object HttpClient { return withContext(Dispatchers.IO) { try { - pick(needsMoreTime(headers), singleAttempt).newCall(request).execute() + pick(needsMoreTime(url, headers), singleAttempt).newCall(request).execute() } catch (e: Exception) { throw e } From 472ec3af52585277e09dfc9bd2e643c146269893 Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 17:13:59 +0800 Subject: [PATCH 26/37] fix(pokemon): keep a stale answer from unlocking the page, and do not call a battle ended A resume or a newer action makes an answer stale; it no longer clears the busy state or counts a round, so the page cannot be unlocked while a non-idempotent write is still running. A defeat that could not be ended (no battle id anywhere) is not reported as done either; only 'the battle is already over' is. --- lib/features/pokemon/cubit/battle_cubit.dart | 30 ++++++++++++------- ...test_181_pokemon_cubit_lifecycle_test.dart | 19 ++++++++++++ 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/lib/features/pokemon/cubit/battle_cubit.dart b/lib/features/pokemon/cubit/battle_cubit.dart index 47d65254..aa9c99f9 100644 --- a/lib/features/pokemon/cubit/battle_cubit.dart +++ b/lib/features/pokemon/cubit/battle_cubit.dart @@ -356,8 +356,11 @@ class BattleCubit extends Cubit with LoggerMixin { } try { final scene = BattleScene.fromMap(data); - if (scene.isActive) _turn++; - if (!isClosed) _emitSceneIfCurrent(scene, generation); + // A stale answer (a resume or a newer action came in) must not touch the round count or the scene. + if (generation == _generation) { + if (scene.isActive) _turn++; + if (!isClosed) _emitSceneIfCurrent(scene, generation); + } return BattleUseItemResult(scene: scene); } on Object { return const BattleUseItemResult(message: 'unexpected battle item response'); @@ -367,7 +370,8 @@ class BattleCubit extends Cubit with LoggerMixin { } on Object catch (e) { return BattleUseItemResult(message: '$e'); } finally { - if (!isClosed) emit(state.copyWith(actionInProgress: false)); + // The busy state belongs to the action that is actually running: a stale answer must not unlock the page. + if (!isClosed && generation == _generation) emit(state.copyWith(actionInProgress: false)); } } @@ -404,16 +408,20 @@ class BattleCubit extends Cubit with LoggerMixin { return BattleActionResult(success: false, message: _messageOf(e)); }, (scene) { - // A turn the server accepted and that left the battle running is one more round; ending it does not count. - if (scene.isActive) _turn++; - if (!isClosed) _emitSceneIfCurrent(scene, generation); + // A turn the server accepted and that left the battle running is one more round; ending it does not count. A + // resume (or a newer action) makes this answer stale, and a stale answer must not touch the round count. + if (generation == _generation) { + if (scene.isActive) _turn++; + if (!isClosed) _emitSceneIfCurrent(scene, generation); + } return const BattleActionResult(success: true); }, ); } on Object catch (e) { return BattleActionResult(success: false, message: '$e'); } finally { - if (!isClosed) emit(state.copyWith(actionInProgress: false)); + // The busy state belongs to the action that is actually running: a stale answer must not unlock the page. + if (!isClosed && generation == _generation) emit(state.copyWith(actionInProgress: false)); } } @@ -475,6 +483,7 @@ class BattleCubit extends Cubit with LoggerMixin { final instanceId = scene.myPokemon.instanceId > 0 ? scene.myPokemon.instanceId : running?.myPokemon.instanceId ?? 0; final battleId = scene.battleId.isNotEmpty ? scene.battleId : running?.battleId ?? ''; + AppException? healError; if (instanceId > 0) { final healed = await _repository.healAndFlee(instanceId).run(); if (isClosed) return const BattleActionResult(success: false); @@ -486,16 +495,17 @@ class BattleCubit extends Cubit with LoggerMixin { } // The server had already cleared the battle (no usable backup, or another client ended it): heal directly, so the // pet is restored even when there is nothing left to end. - final healError = healed.fold((e) => e, (_) => null); + healError = healed.fold((e) => e, (_) => null); if (battleAlreadyOverError(healError)) { return healPet(instanceId); } } if (battleId.isEmpty) { - // Nothing left to end the battle with: heal what the party needs, and the page remembers the battle as over. + // Nothing left to end the battle with. The party is healed anyway, but the battle may well still be running on the + // server, so this is not reported as done (only "the battle is already over" is). await healParty(); - return const BattleActionResult(success: true); + return BattleActionResult(success: false, message: _messageOf(healError ?? HttpRequestFailedException(null))); } final fled = await _repository.flee(battleId).run(); diff --git a/test/regression/test_181_pokemon_cubit_lifecycle_test.dart b/test/regression/test_181_pokemon_cubit_lifecycle_test.dart index 672fd88c..bde6458c 100644 --- a/test/regression/test_181_pokemon_cubit_lifecycle_test.dart +++ b/test/regression/test_181_pokemon_cubit_lifecycle_test.dart @@ -320,4 +320,23 @@ void main() { expect(paths.where((path) => path.contains('action=flee')), hasLength(1)); expect(paths.where((path) => path.contains('heal_and_flee')), isEmpty); }); + + test('does not report a battle as ended when nothing could end it', () async { + useFakeForum(); + final cubit = BattleCubit(); + addTearDown(cubit.close); + + // The battle was never seen running, and the end answer names neither the battle nor the pokemon. + final result = await cubit.finishDefeat( + BattleScene.fromMap(const { + 'battle_id': '', + 'map_id': 3, + 'status': 'defeat', + 'my_pokemon': {'instance_id': 0, 'hp': 0, 'max_hp': 20}, + 'wild_pokemon': {'id': 25, 'hp': 1, 'max_hp': 18}, + }), + ); + + expect(result.success, isFalse, reason: 'the battle may still be running on the server'); + }); } From 1f49ca0f9c515cf3eb55ef05eb1cfdb01f02cc53 Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 17:14:00 +0800 Subject: [PATCH 27/37] fix(pokemon): read the money and the bag back after a purchase that lost its answer buy and buyPet return a failure when the transport failed, but the server may still have carried the purchase out, so the money, the bag and the party are read again before the player tries a second time. --- lib/features/pokemon/cubit/pokemon_cubit.dart | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/lib/features/pokemon/cubit/pokemon_cubit.dart b/lib/features/pokemon/cubit/pokemon_cubit.dart index 7d9fc211..1e41fec2 100644 --- a/lib/features/pokemon/cubit/pokemon_cubit.dart +++ b/lib/features/pokemon/cubit/pokemon_cubit.dart @@ -554,7 +554,14 @@ class PokemonCubit extends Cubit { Left(:final value) => PokemonActionResult(success: false, message: _messageOf(value)), Right(:final value) => PokemonActionResult(success: true, message: value.message), }; - if (actionResult.success) await Future.wait([refreshShop(), refreshProfile()]); + if (actionResult.success) { + await Future.wait([refreshShop(), refreshProfile()]); + } else if (actionResult.message == null) { + // A purchase that lost its answer may have gone through: read the money and the bag back before the player + // tries again. + await _reloadAfterLostAnswer(); + await refreshShop(); + } return actionResult; } on Object catch (e) { return PokemonActionResult(success: false, message: '$e'); @@ -572,7 +579,15 @@ class PokemonCubit extends Cubit { Left(:final value) => PokemonActionResult(success: false, message: _messageOf(value)), Right(:final value) => PokemonActionResult(success: true, message: value.message), }; - if (actionResult.success) await Future.wait([refreshShop(), refreshProfile(), refreshPokemons()]); + if (actionResult.success) { + await Future.wait([refreshShop(), refreshProfile(), refreshPokemons()]); + } else if (actionResult.message == null) { + // A purchase that lost its answer may have gone through: read the money, the bag and the party back before the + // player tries again. + await _reloadAfterLostAnswer(); + await refreshShop(); + await refreshPokemons(); + } return actionResult; } on Object catch (e) { return PokemonActionResult(success: false, message: '$e'); From cf93789fee0f2fbd3c7ab5aa83c9c81720060970 Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 17:31:46 +0800 Subject: [PATCH 28/37] fix(net): only keep recover's 404 out of the error banner The whole plugin api was exempt, so a plugin that is disabled or renamed answered 404 without ever showing up in the log or the banner. Only the recover call, whose 404 means no battle is running, stays silent now; the image requests keep their own exemption. --- .../net_client_provider/net_client_provider.dart | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/shared/providers/net_client_provider/net_client_provider.dart b/lib/shared/providers/net_client_provider/net_client_provider.dart index 684fb577..d1042ce1 100644 --- a/lib/shared/providers/net_client_provider/net_client_provider.dart +++ b/lib/shared/providers/net_client_provider/net_client_provider.dart @@ -398,12 +398,14 @@ class _ErrorHandler extends Interceptor with LoggerMixin { /// Whether a 404 is an answer the app asked for rather than a page it lost. /// - /// The pet sprites sit next to the forum and half the pokemon have none, and the plugin answers 404 for `recover` - /// while no battle is running: both are normal answers, kept out of the error log and the network error banner. A - /// forum page that is really gone still reports normally. + /// A pokemon sprite that is not on the cdn and the plugin's `recover` (which answers 404 while no battle is running) + /// are normal answers, kept out of the error log and the network error banner. A plugin that is disabled or renamed + /// answers 404 as well, and that is not an answer anybody asked for: it keeps reporting. bool _isExpectedMissing(RequestOptions options) { final accept = options.headers[HttpHeaders.acceptHeader]?.toString() ?? ''; - return accept.contains('image/') || options.uri.queryParameters['id'] == 'pokemon:pokemon'; + if (accept.contains('image/')) return true; + final query = options.uri.queryParameters; + return query['id'] == 'pokemon:pokemon' && query['endpoint'] == 'battle' && query['action'] == 'recover'; } @override From 1d7c880f3d41c862e28f88db2bcbf1928991044f Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 17:31:46 +0800 Subject: [PATCH 29/37] fix(pokemon): drop a resume's scene if a newer action took over onAppResumed read the battle back and painted it unconditionally; an action the player started while that read was in flight now owns the screen, and the stale read-back is dropped like any other. --- lib/features/pokemon/cubit/battle_cubit.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/features/pokemon/cubit/battle_cubit.dart b/lib/features/pokemon/cubit/battle_cubit.dart index aa9c99f9..fb0dced3 100644 --- a/lib/features/pokemon/cubit/battle_cubit.dart +++ b/lib/features/pokemon/cubit/battle_cubit.dart @@ -296,7 +296,9 @@ class BattleCubit extends Cubit with LoggerMixin { /// wait and read the battle again from the server. The answers of requests that were in flight are dropped. Future onAppResumed() async { if (!state.actionInProgress) return BattleResumeResult.nothing; - _generation++; + // A resume is a new generation: answers that were in flight are dropped, including this read-back's own scene if the + // player started an action while the battle was being read again. + final generation = ++_generation; emit(state.copyWith(actionInProgress: false)); final result = await _repository.recoverBattle().run(); if (isClosed) return BattleResumeResult.failed; @@ -307,7 +309,7 @@ class BattleCubit extends Cubit with LoggerMixin { unawaited(healParty()); return BattleResumeResult.gone; } - _emitScene(scene, status: BattleStatus.success, actionInProgress: false); + _emitSceneIfCurrent(scene, generation, status: BattleStatus.success, actionInProgress: false); return BattleResumeResult.refreshed; } From dde6cd8a84c326fe551481896dfcb3a12c91d483 Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 17:31:47 +0800 Subject: [PATCH 30/37] fix(pokemon): keep a stale list answer out of the current category loadMoreShop, loadMoreInventory, refreshShop and refreshInventory merged or replaced a list without checking that the category (or the page) was still the one the answer belongs to, so a switch made mid-request glued the old entries onto the new list and moved the page counter. --- lib/features/pokemon/cubit/pokemon_cubit.dart | 97 ++++++++++++------- 1 file changed, 61 insertions(+), 36 deletions(-) diff --git a/lib/features/pokemon/cubit/pokemon_cubit.dart b/lib/features/pokemon/cubit/pokemon_cubit.dart index 1e41fec2..9761ee86 100644 --- a/lib/features/pokemon/cubit/pokemon_cubit.dart +++ b/lib/features/pokemon/cubit/pokemon_cubit.dart @@ -346,7 +346,11 @@ class PokemonCubit extends Cubit { Future refreshInventory() async { final result = await _repository.getInventory(page: state.inventoryPage, type: state.inventoryCategory.type).run(); if (isClosed) return; - result.fold((_) => null, (inventory) => emit(state.copyWith(inventory: inventory))); + // The player may have moved to another page while the answer was on its way: it would replace the wrong list. + result.fold((_) => null, (inventory) { + if (inventory.page != state.inventoryPage) return; + emit(state.copyWith(inventory: inventory)); + }); } /// Switch the inventory to [category] and load its first page. @@ -360,12 +364,20 @@ class PokemonCubit extends Cubit { if (state.shopCategory.isPet) { final result = await _repository.getShopPets(page: state.shopPage).run(); if (isClosed) return; - result.fold((_) => null, (pets) => emit(state.copyWith(shopPets: pets))); + // The player may have switched category while the answer was on its way: it belongs to the old one. + result.fold((_) => null, (pets) { + if (!state.shopCategory.isPet || state.shopPage != pets.page) return; + emit(state.copyWith(shopPets: pets)); + }); return; } final result = await _repository.getShop(page: state.shopPage, type: state.shopCategory.type).run(); if (isClosed) return; - result.fold((_) => null, (shop) => emit(state.copyWith(shop: shop))); + // Same for the items: a category switch makes this answer stale. + result.fold((_) => null, (shop) { + if (state.shopCategory.isPet || state.shopPage != shop.page) return; + emit(state.copyWith(shop: shop)); + }); } /// Switch the shop to [category] and load its first page. @@ -385,18 +397,23 @@ class PokemonCubit extends Cubit { if (isClosed) return; result.fold( (_) => null, - (page) => emit( - state.copyWith( - inventory: InventoryPage( - items: [...current.items, ...page.items], - total: page.total, - page: page.page, - perPage: page.perPage, - totalPages: page.totalPages, + (page) { + // The player may have switched category (or gone to another page) while this answer was on its way, and that + // page does not belong to the list that is on screen now. + if (state.inventoryPage != next) return; + emit( + state.copyWith( + inventory: InventoryPage( + items: [...current.items, ...page.items], + total: page.total, + page: page.page, + perPage: page.perPage, + totalPages: page.totalPages, + ), + inventoryPage: next, ), - inventoryPage: next, - ), - ), + ); + }, ); } finally { _loadingMoreInventory = false; @@ -429,18 +446,22 @@ class PokemonCubit extends Cubit { if (isClosed) return; result.fold( (_) => null, - (page) => emit( - state.copyWith( - shopPets: ShopPetsPage( - pets: [...current.pets, ...page.pets], - total: page.total, - page: page.page, - perPage: page.perPage, - totalPages: page.totalPages, + (page) { + // The player may have switched category while this answer was on its way: it does not belong to that list. + if (!state.shopCategory.isPet || state.shopPage != next) return; + emit( + state.copyWith( + shopPets: ShopPetsPage( + pets: [...current.pets, ...page.pets], + total: page.total, + page: page.page, + perPage: page.perPage, + totalPages: page.totalPages, + ), + shopPage: next, ), - shopPage: next, - ), - ), + ); + }, ); return; } @@ -451,18 +472,22 @@ class PokemonCubit extends Cubit { if (isClosed) return; result.fold( (_) => null, - (page) => emit( - state.copyWith( - shop: ShopPage( - items: [...current.items, ...page.items], - total: page.total, - page: page.page, - perPage: page.perPage, - totalPages: page.totalPages, + (page) { + // The player may have switched category while this answer was on its way: it does not belong to that list. + if (state.shopCategory.isPet || state.shopPage != next) return; + emit( + state.copyWith( + shop: ShopPage( + items: [...current.items, ...page.items], + total: page.total, + page: page.page, + perPage: page.perPage, + totalPages: page.totalPages, + ), + shopPage: next, ), - shopPage: next, - ), - ), + ); + }, ); } finally { _loadingMoreShop = false; From cd2e19ee84918a3af9562ee940f17c2cf1e62fb4 Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 18:04:25 +0800 Subject: [PATCH 31/37] fix(pokemon): reload the lists from their first page and for the selected category loadMoreShop and loadMoreInventory accumulate pages, so a refresh (which happens after every purchase) that replaced the list with the page the player had reached dropped every page before it, and loadMore only goes forward. The refresh paths read the first page again and reset the counters. load() also read the shop without its category, so a stone category held the whole item list. --- lib/features/pokemon/cubit/pokemon_cubit.dart | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/lib/features/pokemon/cubit/pokemon_cubit.dart b/lib/features/pokemon/cubit/pokemon_cubit.dart index 9761ee86..4e1b8f9e 100644 --- a/lib/features/pokemon/cubit/pokemon_cubit.dart +++ b/lib/features/pokemon/cubit/pokemon_cubit.dart @@ -200,11 +200,10 @@ class PokemonCubit extends Cubit { final configFuture = _repository.getGlobalConfig().run(); final pokemonsFuture = _repository.getMyPokemon().run(); final profileFuture = _repository.getProfile().run(); - final inventoryFuture = _repository.getInventory( - page: state.inventoryPage, - type: state.inventoryCategory.type, - ).run(); - final shopFuture = _repository.getShop(page: state.shopPage).run(); + // The lists are read from their first page: pages held before belong to the list that was on screen, and keeping + // only the page the player had reached would drop everything before it. + final inventoryFuture = _repository.getInventory(type: state.inventoryCategory.type).run(); + final shopFuture = _repository.getShop(type: state.shopCategory.type).run(); final config = await configFuture; final pokemons = await pokemonsFuture; @@ -231,7 +230,9 @@ class PokemonCubit extends Cubit { pokemons: (pokemons as Right>).value, profile: (profile as Right).value, inventory: (inventory as Right).value, + inventoryPage: 1, shop: (shop as Right).value, + shopPage: 1, ), ); // The profile can lag behind the actual status bar pet (or not carry the flag at all), so read it like the website. @@ -343,13 +344,15 @@ class PokemonCubit extends Cubit { } /// Refresh the inventory in place, for the currently selected category. + /// + /// Like the shop, the list starts at its first page again rather than keeping only the page the player had reached. Future refreshInventory() async { - final result = await _repository.getInventory(page: state.inventoryPage, type: state.inventoryCategory.type).run(); + final category = state.inventoryCategory; + final result = await _repository.getInventory(type: category.type).run(); if (isClosed) return; - // The player may have moved to another page while the answer was on its way: it would replace the wrong list. result.fold((_) => null, (inventory) { - if (inventory.page != state.inventoryPage) return; - emit(state.copyWith(inventory: inventory)); + if (state.inventoryCategory != category) return; + emit(state.copyWith(inventory: inventory, inventoryPage: 1)); }); } @@ -360,23 +363,26 @@ class PokemonCubit extends Cubit { } /// Refresh the shop in place, for the currently selected category. + /// + /// The list starts at its first page again: the pages loaded after it belong to the list that was on screen, and + /// replacing them with just the page the player had reached would drop everything before it (with `loadMore` only + /// going forward, only a reload could bring it back). Future refreshShop() async { if (state.shopCategory.isPet) { - final result = await _repository.getShopPets(page: state.shopPage).run(); + final result = await _repository.getShopPets().run(); if (isClosed) return; // The player may have switched category while the answer was on its way: it belongs to the old one. result.fold((_) => null, (pets) { - if (!state.shopCategory.isPet || state.shopPage != pets.page) return; - emit(state.copyWith(shopPets: pets)); + if (!state.shopCategory.isPet) return; + emit(state.copyWith(shopPets: pets, shopPage: 1)); }); return; } - final result = await _repository.getShop(page: state.shopPage, type: state.shopCategory.type).run(); + final result = await _repository.getShop(type: state.shopCategory.type).run(); if (isClosed) return; - // Same for the items: a category switch makes this answer stale. result.fold((_) => null, (shop) { - if (state.shopCategory.isPet || state.shopPage != shop.page) return; - emit(state.copyWith(shop: shop)); + if (state.shopCategory.isPet) return; + emit(state.copyWith(shop: shop, shopPage: 1)); }); } From 380db177bad4820a61494bea5712d9d79b96efd6 Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 18:04:26 +0800 Subject: [PATCH 32/37] fix(pokemon): keep a write alive past the transport timeouts, and store a formhash only for its account The write deadline has to outlast connect + read (15s + 30s on android, counted separately), so it is 45s now and the transport reports its own failure. A formhash read that finishes after the account switched no longer caches the old session's hash for the new one. --- lib/features/pokemon/repository/pokemon_repository.dart | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/features/pokemon/repository/pokemon_repository.dart b/lib/features/pokemon/repository/pokemon_repository.dart index a9e15e90..9f9e83c4 100644 --- a/lib/features/pokemon/repository/pokemon_repository.dart +++ b/lib/features/pokemon/repository/pokemon_repository.dart @@ -51,7 +51,7 @@ final class PokemonRepository with LoggerMixin { /// /// It has to outlast the transport's own read timeout (30s in the android client), or a write that was still in flight /// would be reported as a failure although the server may well have carried it out. - static const _writeDeadline = Duration(seconds: 35); + static const _writeDeadline = Duration(seconds: 45); /// Header the plugin's API expects for its cross-site request check. /// @@ -406,6 +406,7 @@ final class PokemonRepository with LoggerMixin { /// Read the formhash from the plugin page and remember it; a failed read is not cached. Future _readFormHash() async { + final uid = getIt.get().userLoginInfo.uid; // The page read runs under the same deadline as the call that waits for it: a parked read must not hold it. final resp = await _net .get(_formHashPageUrl, options: _options()) @@ -420,7 +421,11 @@ final class PokemonRepository with LoggerMixin { warning('failed to read the formhash from $_formHashPageUrl'); return null; } - _formHash = hash; + // The account may have switched while the page was on its way: that hash belongs to the old session. + if (getIt.get().userLoginInfo.uid == uid) { + _formHash = hash; + _formHashUid = uid; + } return hash; } From be88c3e7b84a6bcfc3cde795175477934ee7b224 Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 18:35:12 +0800 Subject: [PATCH 33/37] fix(pokemon): compare the page a load-more started from The guard added for the category race compared the jump target instead of the page the request started from, so the next page could never land and the counter never moved. The test for the list refresh caught it. --- lib/features/pokemon/cubit/pokemon_cubit.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/features/pokemon/cubit/pokemon_cubit.dart b/lib/features/pokemon/cubit/pokemon_cubit.dart index 4e1b8f9e..1fc484b1 100644 --- a/lib/features/pokemon/cubit/pokemon_cubit.dart +++ b/lib/features/pokemon/cubit/pokemon_cubit.dart @@ -405,8 +405,8 @@ class PokemonCubit extends Cubit { (_) => null, (page) { // The player may have switched category (or gone to another page) while this answer was on its way, and that - // page does not belong to the list that is on screen now. - if (state.inventoryPage != next) return; + // page does not belong to the list that is on screen now. `next - 1` is the page the request started from. + if (state.inventoryPage != next - 1) return; emit( state.copyWith( inventory: InventoryPage( @@ -454,7 +454,7 @@ class PokemonCubit extends Cubit { (_) => null, (page) { // The player may have switched category while this answer was on its way: it does not belong to that list. - if (!state.shopCategory.isPet || state.shopPage != next) return; + if (!state.shopCategory.isPet || state.shopPage != next - 1) return; emit( state.copyWith( shopPets: ShopPetsPage( @@ -480,7 +480,7 @@ class PokemonCubit extends Cubit { (_) => null, (page) { // The player may have switched category while this answer was on its way: it does not belong to that list. - if (state.shopCategory.isPet || state.shopPage != next) return; + if (state.shopCategory.isPet || state.shopPage != next - 1) return; emit( state.copyWith( shop: ShopPage( From 00fed85c40c069b015c1a44c0bbe09a10231a72a Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 18:35:12 +0800 Subject: [PATCH 34/37] test(pokemon): cover the list refresh reset and the 404 exemption A refresh after loading more pages has to read the first page again and move the counter back with it, and only the plugin's recover 404 (and an image that is missing) may stay out of the network error banner. --- ...test_181_pokemon_cubit_lifecycle_test.dart | 33 ++++++++++ .../test_184_net_error_banner_test.dart | 60 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 test/regression/test_184_net_error_banner_test.dart diff --git a/test/regression/test_181_pokemon_cubit_lifecycle_test.dart b/test/regression/test_181_pokemon_cubit_lifecycle_test.dart index bde6458c..6dfb5265 100644 --- a/test/regression/test_181_pokemon_cubit_lifecycle_test.dart +++ b/test/regression/test_181_pokemon_cubit_lifecycle_test.dart @@ -80,6 +80,14 @@ void main() { 'data': {'status_bar_hidden': serverStatusBarHidden}, }); } + // Two pages of shop and bag items, so a load-more can advance the page counter. + if (action == 'inventory' || (action == 'list' && uri.queryParameters['endpoint'] == 'shop')) { + final page = int.tryParse(uri.queryParameters['page'] ?? '1') ?? 1; + return jsonEncode({ + 'success': true, + 'data': {'items': [], 'total': 30, 'page': page, 'per_page': 20, 'total_pages': 2}, + }); + } if (action == 'list') { return jsonEncode({ 'success': true, @@ -339,4 +347,29 @@ void main() { expect(result.success, isFalse, reason: 'the battle may still be running on the server'); }); + + test('a refresh reads the lists from their first page again', () async { + useFakeForum(); + final cubit = PokemonCubit(); + addTearDown(cubit.close); + await cubit.load(); + + // The player scrolled one page into both lists. + await cubit.loadMoreShop(); + await cubit.loadMoreInventory(); + expect(cubit.state.shopPage, 2); + expect(cubit.state.inventoryPage, 2); + paths.clear(); + + await cubit.refreshShop(); + await cubit.refreshInventory(); + + // The pages loaded before belong to the list that was on screen: a refresh asks for the first page again and moves + // the counter back with it (keeping only the page the player had reached would drop everything before it, and + // loadMore only goes forward). + expect(paths.where((path) => path.contains('endpoint=shop') && path.contains('page=1')), hasLength(1)); + expect(paths.where((path) => path.contains('action=inventory') && path.contains('page=1')), hasLength(1)); + expect(cubit.state.shopPage, 1); + expect(cubit.state.inventoryPage, 1); + }); } diff --git a/test/regression/test_184_net_error_banner_test.dart b/test/regression/test_184_net_error_banner_test.dart new file mode 100644 index 00000000..8bcdef45 --- /dev/null +++ b/test/regression/test_184_net_error_banner_test.dart @@ -0,0 +1,60 @@ +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:talker_flutter/talker_flutter.dart'; +import 'package:tsdm_client/instance.dart'; +import 'package:tsdm_client/shared/providers/cookie_provider/cookie_provider.dart'; +import 'package:tsdm_client/shared/providers/net_client_provider/net_client_provider.dart'; +import 'package:tsdm_client/shared/providers/net_client_provider/net_error_saver.dart'; + +/// Answers every request with a 404, the way a plugin that is disabled or a page that is gone does. +/// +/// The interceptors of the client stay in place: the point of this test is what the error handler does with the answer. +final class _MissingAdapter implements HttpClientAdapter { + @override + Future fetch(RequestOptions options, Stream? stream, Future? cancel) async => + ResponseBody.fromString('', 404); + + @override + void close({bool force = false}) {} +} + +void main() { + setUpAll(() => talker = TalkerFlutter.init(settings: TalkerSettings(enabled: false))); + + setUp(() { + final dio = Dio()..httpClientAdapter = _MissingAdapter(); + getIt + ..registerSingleton(NetErrorSaver()) + ..registerSingleton( + NetClientProvider.buildNoCookie(dio: dio, cookie: CookieProvider.buildEmpty()), + ); + }); + + tearDown(getIt.reset); + + test('a 404 that means the plugin has no battle stays out of the error banner', () async { + await getIt + .get() + .get('https://x/plugin.php?id=pokemon:pokemon&endpoint=battle&action=recover&mobile=no') + .run(); + + expect(getIt.get().error(), isNull); + }); + + test('a plugin 404 for any other call still reports', () async { + await getIt + .get() + .get('https://x/plugin.php?id=pokemon:pokemon&endpoint=pokemon&action=list&mobile=no') + .run(); + + expect(getIt.get().error(), isNotNull); + }); + + test('a forum page that is gone still reports', () async { + await getIt.get().get('https://x/forum.php?mod=viewthread&tid=1').run(); + + expect(getIt.get().error(), isNotNull); + }); +} From a9cae0b3524433be6a6393933d4dc8df613c9bea Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 18:58:23 +0800 Subject: [PATCH 35/37] fix(pokemon): keep a stale start failure from switching the page state start guarded its success path with the generation but not its failure path, so an answer that a resume or a newer action had already made stale could still switch the page to the failure state and unlock it. The write deadline comment now also says that the formhash read happens before it. --- lib/features/pokemon/cubit/battle_cubit.dart | 18 +++++++++++------- .../pokemon/repository/pokemon_repository.dart | 3 ++- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/lib/features/pokemon/cubit/battle_cubit.dart b/lib/features/pokemon/cubit/battle_cubit.dart index fb0dced3..b1d7613c 100644 --- a/lib/features/pokemon/cubit/battle_cubit.dart +++ b/lib/features/pokemon/cubit/battle_cubit.dart @@ -192,13 +192,17 @@ class BattleCubit extends Cubit with LoggerMixin { await start(mapId, bossTypeId: bossTypeId, keepScene: keepScene, retryAfterHeal: false); return; } - emit( - state.copyWith( - status: _isNeedLogin(value) ? BattleStatus.needLogin : BattleStatus.failure, - failureMessage: _messageOf(value), - actionInProgress: false, - ), - ); + // A resume (or a newer action) makes this answer stale: it must not switch the page to the failure state, nor + // unlock a page another action is running on. + if (generation == _generation) { + emit( + state.copyWith( + status: _isNeedLogin(value) ? BattleStatus.needLogin : BattleStatus.failure, + failureMessage: _messageOf(value), + actionInProgress: false, + ), + ); + } case Right(:final value): // The client started this battle itself, so it is not one the adventure page already saw end: on the same map // against the same species with the same own pokemon the key repeats. diff --git a/lib/features/pokemon/repository/pokemon_repository.dart b/lib/features/pokemon/repository/pokemon_repository.dart index 9f9e83c4..6a572f01 100644 --- a/lib/features/pokemon/repository/pokemon_repository.dart +++ b/lib/features/pokemon/repository/pokemon_repository.dart @@ -50,7 +50,8 @@ final class PokemonRepository with LoggerMixin { /// Deadline of a write. /// /// It has to outlast the transport's own read timeout (30s in the android client), or a write that was still in flight - /// would be reported as a failure although the server may well have carried it out. + /// would be reported as a failure although the server may well have carried it out. Reading the session's formhash + /// (up to [_requestDeadline]) happens before it, so one call can take longer than this deadline on its own. static const _writeDeadline = Duration(seconds: 45); /// Header the plugin's API expects for its cross-site request check. From d601a09e6c671c567e760487617627253ca0e447 Mon Sep 17 00:00:00 2001 From: bbtu1 Date: Wed, 30 Sep 2026 19:14:40 +0800 Subject: [PATCH 36/37] fix(pokemon): keep healAndFlee's busy state from a stale answer too It emitted the busy state and cleared it in its finally without checking the generation, so a resume or a newer action could have its busy state cleared by a heal that started before it. fightAgain needs no such guard: it has no finally of its own and leaves the flag to start. --- lib/features/pokemon/cubit/battle_cubit.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/features/pokemon/cubit/battle_cubit.dart b/lib/features/pokemon/cubit/battle_cubit.dart index b1d7613c..00e96c74 100644 --- a/lib/features/pokemon/cubit/battle_cubit.dart +++ b/lib/features/pokemon/cubit/battle_cubit.dart @@ -229,6 +229,8 @@ class BattleCubit extends Cubit with LoggerMixin { if (instanceId <= 0) { return const BattleActionResult(success: false); } + // A resume or a newer action makes this answer stale: the busy state it clears would be theirs, not this one's. + final generation = _generation; emit(state.copyWith(actionInProgress: true)); try { final result = await _repository.healAndFlee(instanceId).run(); @@ -239,7 +241,7 @@ class BattleCubit extends Cubit with LoggerMixin { } on Object catch (e) { return BattleActionResult(success: false, message: '$e'); } finally { - if (!isClosed) emit(state.copyWith(actionInProgress: false)); + if (!isClosed && generation == _generation) emit(state.copyWith(actionInProgress: false)); } } From 26a0b6dfd6578960fb00238e2ba76d508620b3d0 Mon Sep 17 00:00:00 2001 From: Carinoasd <46304809+Carinoasd@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:53:03 +0800 Subject: [PATCH 37/37] fix(pokemon): drop a load-more a category switch overtook, and heal the right pet on fight again - loadMore* compares the list it started from instead of the page number: a category switch or a refresh resets the page to 1, the very page most load-mores start from, so the old guard let the old category's pages through. - refreshShop's item branch compares the category it asked for. - A stale battle action that lost its answer no longer reads the battle back over a newer action's scene. - fightAgain takes the pet id from the running scene when the end scene lost it, and heals the party without one. - _runVoid reads the party back too after a lost answer (release, move, set first and rename change it). - A failed image cache write drops the truncated file. Adds four regression tests to test_181. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/features/pokemon/cubit/battle_cubit.dart | 17 ++- lib/features/pokemon/cubit/pokemon_cubit.dart | 32 +++-- .../image_cache_provider.dart | 7 + ...test_181_pokemon_cubit_lifecycle_test.dart | 126 ++++++++++++++++++ 4 files changed, 167 insertions(+), 15 deletions(-) diff --git a/lib/features/pokemon/cubit/battle_cubit.dart b/lib/features/pokemon/cubit/battle_cubit.dart index 00e96c74..311025aa 100644 --- a/lib/features/pokemon/cubit/battle_cubit.dart +++ b/lib/features/pokemon/cubit/battle_cubit.dart @@ -283,7 +283,16 @@ class BattleCubit extends Cubit with LoggerMixin { emit(state.copyWith(actionInProgress: true)); final scene = state.scene; if (scene != null && _scenePetNeedsHealing(scene)) { - await healPet(scene.myPokemon.instanceId); + // The end-of-battle answer may leave the pokemon's id at 0, and healing pokemon 0 would fail silently and send the + // pet into the next fight hurt: take the id from the last running scene, or heal the whole party without one. + final instanceId = scene.myPokemon.instanceId > 0 + ? scene.myPokemon.instanceId + : _runningScene?.myPokemon.instanceId ?? 0; + if (instanceId > 0) { + await healPet(instanceId); + } else { + await healParty(); + } if (isClosed) return; } await start(mapId, bossTypeId: bossTypeId, keepScene: true); @@ -411,8 +420,10 @@ class BattleCubit extends Cubit with LoggerMixin { return result.fold( (e) { // The answer never arrived, so the scene on screen may no longer be the server's: read it again instead of - // leaving the player acting on a stale one. - if (_messageOf(e) == null) unawaited(resync()); + // leaving the player acting on a stale one. A stale action (a resume or a newer action came in) must not: the + // read-back would take the newer generation and could paint a scene from before the newer action over it, + // and the resume reads the battle again itself. + if (_messageOf(e) == null && generation == _generation) unawaited(resync()); return BattleActionResult(success: false, message: _messageOf(e)); }, (scene) { diff --git a/lib/features/pokemon/cubit/pokemon_cubit.dart b/lib/features/pokemon/cubit/pokemon_cubit.dart index 1fc484b1..9c0531ac 100644 --- a/lib/features/pokemon/cubit/pokemon_cubit.dart +++ b/lib/features/pokemon/cubit/pokemon_cubit.dart @@ -378,10 +378,12 @@ class PokemonCubit extends Cubit { }); return; } - final result = await _repository.getShop(type: state.shopCategory.type).run(); + final category = state.shopCategory; + final result = await _repository.getShop(type: category.type).run(); if (isClosed) return; result.fold((_) => null, (shop) { - if (state.shopCategory.isPet) return; + // The player may have switched to another item category (or to the pets) while the answer was on its way. + if (state.shopCategory != category) return; emit(state.copyWith(shop: shop, shopPage: 1)); }); } @@ -404,9 +406,10 @@ class PokemonCubit extends Cubit { result.fold( (_) => null, (page) { - // The player may have switched category (or gone to another page) while this answer was on its way, and that - // page does not belong to the list that is on screen now. `next - 1` is the page the request started from. - if (state.inventoryPage != next - 1) return; + // A category switch, a refresh or another load-more replaces the list while this answer is on its way, and the + // page then belongs to a list that is no longer on screen. The page number cannot tell: a switch or a refresh + // resets it to 1, the very page most load-mores start from. + if (!identical(state.inventory, current)) return; emit( state.copyWith( inventory: InventoryPage( @@ -453,8 +456,8 @@ class PokemonCubit extends Cubit { result.fold( (_) => null, (page) { - // The player may have switched category while this answer was on its way: it does not belong to that list. - if (!state.shopCategory.isPet || state.shopPage != next - 1) return; + // Same as the inventory: a switch or a refresh replaced the list, and this page belongs to the old one. + if (!identical(state.shopPets, current)) return; emit( state.copyWith( shopPets: ShopPetsPage( @@ -479,8 +482,8 @@ class PokemonCubit extends Cubit { result.fold( (_) => null, (page) { - // The player may have switched category while this answer was on its way: it does not belong to that list. - if (state.shopCategory.isPet || state.shopPage != next - 1) return; + // Same as the inventory: a switch or a refresh replaced the list, and this page belongs to the old one. + if (!identical(state.shop, current)) return; emit( state.copyWith( shop: ShopPage( @@ -634,11 +637,11 @@ class PokemonCubit extends Cubit { final result = await action().run(); final message = result.fold(_messageOf, (_) => null); // A write that lost its answer may or may not have happened on the server, so read the money and the bag back - // before the player tries again. - if (result.isLeft() && message == null) unawaited(_reloadAfterLostAnswer()); + // (and the party, which these writes change) before the player tries again. + if (result.isLeft() && message == null) unawaited(_reloadAfterLostWrite()); return PokemonActionResult(success: result.isRight(), message: message); } on Object catch (e) { - unawaited(_reloadAfterLostAnswer()); + unawaited(_reloadAfterLostWrite()); return PokemonActionResult(success: false, message: '$e'); } finally { if (!isClosed) emit(state.copyWith(actionInProgress: false)); @@ -651,6 +654,11 @@ class PokemonCubit extends Cubit { await refreshInventory(); } + /// [_reloadAfterLostAnswer] plus the pokemon list, for the writes [_runVoid] runs on the party. + Future _reloadAfterLostWrite() async { + await Future.wait([_reloadAfterLostAnswer(), refreshPokemons()]); + } + /// Find a pokemon by id in the current state, or null. Pokemon? _findPokemon(int id) { final pokemons = state.pokemons; diff --git a/lib/shared/providers/image_cache_provider/image_cache_provider.dart b/lib/shared/providers/image_cache_provider/image_cache_provider.dart index ab424800..5b355315 100644 --- a/lib/shared/providers/image_cache_provider/image_cache_provider.dart +++ b/lib/shared/providers/image_cache_provider/image_cache_provider.dart @@ -397,6 +397,13 @@ final class ImageCacheProvider with LoggerMixin { await cache.writeAsBytes(imageData); } on FileSystemException catch (e) { warning('could not save the image cache (${imageUrl.length} char url): ${e.message}'); + // A write that failed halfway (a full disk) leaves a truncated file; a row an earlier save left for this url would + // then serve a broken image, so drop the file and let the next load download it again. + try { + if (cache.existsSync()) await cache.delete(); + } on FileSystemException catch (_) { + // Best effort: the warning above already reports the failed save. + } return; } diff --git a/test/regression/test_181_pokemon_cubit_lifecycle_test.dart b/test/regression/test_181_pokemon_cubit_lifecycle_test.dart index 6dfb5265..839a5f78 100644 --- a/test/regression/test_181_pokemon_cubit_lifecycle_test.dart +++ b/test/regression/test_181_pokemon_cubit_lifecycle_test.dart @@ -34,9 +34,13 @@ void main() { /// With [turnFails] a battle turn loses the transport (no answer at all); [recoverDelay] holds the read-back after /// such a failure open; [recoveredScene] is what that read-back finds and [startedScene] what the next battle start /// answers, so a scene that changed while an answer was lost can be told apart from the one that was read back. + /// [turnDelay] holds a battle turn open and [pageTwoDelay] the second page of a list, so a newer action or a category + /// switch can overtake them. void useFakeForum({ Duration delay = Duration.zero, bool turnFails = false, + Duration turnDelay = Duration.zero, + Duration pageTwoDelay = Duration.zero, Duration recoverDelay = Duration.zero, Map? recoveredScene, Map? startedScene, @@ -108,6 +112,12 @@ void main() { if (options.uri.queryParameters['action'] == 'recover' && recoverDelay > Duration.zero) { await Future.delayed(recoverDelay); } + if (options.uri.queryParameters['action'] == 'turn' && turnDelay > Duration.zero) { + await Future.delayed(turnDelay); + } + if (options.uri.queryParameters['page'] == '2' && pageTwoDelay > Duration.zero) { + await Future.delayed(pageTwoDelay); + } // A transport failure has no answer at all, which is what the client has to treat as a network problem. if (turnFails && options.uri.queryParameters['action'] == 'turn') { handler.reject(DioException(requestOptions: options, type: DioExceptionType.connectionTimeout)); @@ -372,4 +382,120 @@ void main() { expect(cubit.state.shopPage, 1); expect(cubit.state.inventoryPage, 1); }); + + test('a load-more the player switched category under is dropped', () async { + useFakeForum(pageTwoDelay: const Duration(milliseconds: 150)); + final cubit = PokemonCubit(); + addTearDown(cubit.close); + await cubit.load(); + + // The player scrolls to the end of the first page of both lists... + final inventoryMore = cubit.loadMoreInventory(); + final shopMore = cubit.loadMoreShop(); + // ...and picks another category while the second pages are still on their way. + await cubit.setInventoryCategory(ShopCategory.ball); + await cubit.setShopCategory(ShopCategory.ball); + await Future.wait([inventoryMore, shopMore]); + + // Those pages belong to the lists of the old category: appending them would show them under the new one and skip + // the new list's own second page. The page counter cannot tell, since the switch reset it to 1, the very page the + // load-more started from. + expect(cubit.state.inventoryCategory, ShopCategory.ball); + expect(cubit.state.inventoryPage, 1); + expect(cubit.state.shopCategory, ShopCategory.ball); + expect(cubit.state.shopPage, 1); + }); + + test('a stale action that loses its answer does not read the battle back', () async { + useFakeForum( + turnFails: true, + turnDelay: const Duration(milliseconds: 150), + startedScene: { + 'battle_id': 'battle_2', + 'map_id': 3, + 'status': 'active', + 'my_pokemon': {'instance_id': 7, 'hp': 20, 'max_hp': 20}, + 'wild_pokemon': {'id': 25, 'hp': 9, 'max_hp': 18}, + }, + ); + final cubit = BattleCubit(); + addTearDown(cubit.close); + cubit.resume( + BattleScene.fromMap(const { + 'battle_id': 'battle_1', + 'map_id': 3, + 'status': 'active', + 'my_pokemon': {'instance_id': 7, 'hp': 20, 'max_hp': 20}, + 'wild_pokemon': {'id': 25, 'hp': 18, 'max_hp': 18}, + }), + ); + + final stale = cubit.useSkill(0); + // A newer action takes the screen while the turn is still on its way. + await cubit.start(3); + final result = await stale; + expect(result.success, isFalse); + await Future.delayed(const Duration(milliseconds: 100)); + + // The read-back would take the newer action's generation and could paint a scene from before it over the screen. + expect(paths.where((path) => path.contains('action=recover')), isEmpty); + expect(cubit.state.scene?.battleId, 'battle_2'); + }); + + test('fight again heals the pet the running scene named when the end scene lost its id', () async { + useFakeForum(); + final cubit = BattleCubit(); + addTearDown(cubit.close); + cubit + ..resume( + BattleScene.fromMap(const { + 'battle_id': 'battle_1', + 'map_id': 3, + 'status': 'active', + 'my_pokemon': {'instance_id': 7, 'hp': 20, 'max_hp': 20}, + 'wild_pokemon': {'id': 25, 'hp': 18, 'max_hp': 18}, + }), + ) + // The end-of-battle answer names neither the battle nor the pet, but the pet is hurt. + ..resume( + BattleScene.fromMap(const { + 'battle_id': '', + 'map_id': 3, + 'status': 'victory', + 'my_pokemon': {'instance_id': 0, 'hp': 3, 'max_hp': 20}, + 'wild_pokemon': {'id': 25, 'hp': 0, 'max_hp': 18}, + }), + ); + paths.clear(); + + await cubit.fightAgain(3); + + expect(paths.where((path) => path.contains('action=heal') && path.contains('pokemon_id=7')), hasLength(1)); + expect(paths.where((path) => path.contains('pokemon_id=0')), isEmpty); + expect(paths.where((path) => path.contains('action=start')), hasLength(1)); + }); + + test('fight again heals the party when no scene named the pet', () async { + useFakeForum(); + final cubit = BattleCubit(); + addTearDown(cubit.close); + // The battle was never seen running, and the end answer does not name the pet. + cubit.resume( + BattleScene.fromMap(const { + 'battle_id': '', + 'map_id': 3, + 'status': 'victory', + 'my_pokemon': {'instance_id': 0, 'hp': 3, 'max_hp': 20}, + 'wild_pokemon': {'id': 25, 'hp': 0, 'max_hp': 18}, + }), + ); + paths.clear(); + + await cubit.fightAgain(3); + + // Healing pokemon 0 would fail silently; the party list names the pets that need it instead. + expect(paths.where((path) => path.contains('pokemon_id=0')), isEmpty); + expect(paths.where((path) => path.contains('action=list')), hasLength(1)); + expect(paths.where((path) => path.contains('action=start')), hasLength(1)); + }); }