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 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..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 @@ -4,28 +4,60 @@ 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 { - private val client by lazy { - OkHttpClient.Builder().proxySelector(ProxySelector.getDefault()).build() - } + // 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() } - // Share connections and dispatchers, but never replay a non-idempotent transaction. - private val singleAttemptClient by lazy { + // 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() - .retryOnConnectionFailure(false) - .followRedirects(false) - .followSslRedirects(false) + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) .build() } + // Share connections and dispatchers, but never replay a non-idempotent transaction. + 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 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/") + } + return wantsImage || headers.keys.any { it.equals("X-Pm-Formhash", ignoreCase = true) } + } + suspend fun get(url: String, headers: HashMap): Response { val request = Request.Builder() .url(url) @@ -35,7 +67,7 @@ object HttpClient { return withContext(Dispatchers.IO) { try { - client.newCall(request).execute() + pick(needsMoreTime(url, headers), false).newCall(request).execute() } catch (e: Exception) { throw e } @@ -103,6 +135,38 @@ object HttpClient { } } } + + 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())) + .post(requestBody) + .build() + + return withContext(Dispatchers.IO) { + try { + pick(needsMoreTime(url, headers), singleAttempt).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..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 @@ -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,25 @@ class MainActivity: FlutterActivity() { } } + HTTP_POST_JSON -> { + 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, singleAttempt = singleAttempt) + 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/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/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/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 校验)。 diff --git a/lib/features/pokemon/cubit/battle_cubit.dart b/lib/features/pokemon/cubit/battle_cubit.dart new file mode 100644 index 00000000..311025aa --- /dev/null +++ b/lib/features/pokemon/cubit/battle_cubit.dart @@ -0,0 +1,550 @@ +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/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. +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. + /// + /// [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, + int? wildGender, + bool? wildShiny, + List? skillOrder, + int? turn, + bool? actionInProgress, + String? failureMessage, + bool clearFailure = false, + }) => 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: clearFailure ? null : 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; + + /// 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; + + /// 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; + // 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(); + 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; + } + // 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. + getIt.get().forgetFinishedBattle(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); + } + // 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(); + return result.fold( + (e) => BattleActionResult(success: false, message: _messageOf(e)), + (heal) => BattleActionResult(success: true, message: heal.message), + ); + } on Object catch (e) { + return BattleActionResult(success: false, message: '$e'); + } finally { + if (!isClosed && generation == _generation) 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)) { + // 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); + } + + /// 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; + // 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; + 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; + } + _emitSceneIfCurrent(scene, generation, 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 generation = _generation; + final result = await _repository.recoverBattle().run(); + if (isClosed || result.isLeft()) return; + final scene = result.fold((_) => null, (value) => value); + if (scene == null) return; + // 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]. + 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 { + // 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(); + 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); + // 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'); + } + }, + ); + } on Object catch (e) { + return BattleUseItemResult(message: '$e'); + } finally { + // 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)); + } + } + + /// 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 { + // 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(); + 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. 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) { + // 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 { + // 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)); + } + } + + /// 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, + clearFailure: true, + 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)); + // 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. + 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 { + // 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 ?? ''; + + AppException? healError; + 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. + healError = healed.fold((e) => e, (_) => null); + if (battleAlreadyOverError(healError)) { + return healPet(instanceId); + } + } + + if (battleId.isEmpty) { + // 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 BattleActionResult(success: false, message: _messageOf(healError ?? HttpRequestFailedException(null))); + } + + 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), + ); + } + + /// 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..9c0531ac --- /dev/null +++ b/lib/features/pokemon/cubit/pokemon_cubit.dart @@ -0,0 +1,694 @@ +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'; + +/// 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(); + // 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; + 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, + 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. + 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) ?? false); + 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. + /// + /// 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 category = state.inventoryCategory; + final result = await _repository.getInventory(type: category.type).run(); + if (isClosed) return; + result.fold((_) => null, (inventory) { + if (state.inventoryCategory != category) return; + emit(state.copyWith(inventory: inventory, inventoryPage: 1)); + }); + } + + /// 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. + /// + /// 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().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) return; + emit(state.copyWith(shopPets: pets, shopPage: 1)); + }); + return; + } + final category = state.shopCategory; + final result = await _repository.getShop(type: category.type).run(); + if (isClosed) return; + result.fold((_) => null, (shop) { + // 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)); + }); + } + + /// 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) { + // 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( + 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) { + // 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( + 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) { + // 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( + 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 Object 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()]); + } 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'); + } 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()]); + } 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'); + } 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(); + 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 + // (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(_reloadAfterLostWrite()); + 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(); + } + + /// [_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; + 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; +} 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/features/pokemon/repository/adventure_cache.dart b/lib/features/pokemon/repository/adventure_cache.dart new file mode 100644 index 00000000..94a390f2 --- /dev/null +++ b/lib/features/pokemon/repository/adventure_cache.dart @@ -0,0 +1,157 @@ +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)); + } + + /// 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(); + 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..6a572f01 --- /dev/null +++ b/lib/features/pokemon/repository/pokemon_repository.dart @@ -0,0 +1,535 @@ +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); + + /// 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. 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. + /// + /// 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; + + /// 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; + + /// 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`. + /// + /// 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) { + Left(:final value) => left(value), + Right(:final value) => _decodeEnvelope(value).fold( + (e) => left(e), + (_) => right(null), + ), + }; + }, deadline: _writeDeadline); + + /// 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, singleAttempt: true) + .run(); + return switch (resp) { + 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). + /// + /// 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}) { + // 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; + _formHashUid = uid; + } + if (!refresh) { + final cached = _formHash; + if (cached != null) return Future.value(cached); + final inFlight = _formHashRead; + if (inFlight != null) return inFlight; + } + // 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 { + 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()) + .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; + } + // 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; + } + + /// 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, singleAttempt: true) + .run(); + return switch (resp) { + 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 { + 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/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_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))); + } +} 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..6f283547 --- /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 ?? 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); + 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), + ), + ), + ], + ), + ); + } +} 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/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": "使用中", 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/image_cache_provider/image_cache_provider.dart b/lib/shared/providers/image_cache_provider/image_cache_provider.dart index d0ffa00b..5b355315 100644 --- a/lib/shared/providers/image_cache_provider/image_cache_provider.dart +++ b/lib/shared/providers/image_cache_provider/image_cache_provider.dart @@ -384,15 +384,31 @@ 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; } - 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. 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}'); + // 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; + } + + // Update image cache info to database. + await getIt.get().updateImageCache(imageUrl, fileName: fileName); // Update other cache ref tables, if necessary. switch (usage) { 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..d1042ce1 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,44 @@ 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. + /// + /// [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( + path, + data: data, + queryParameters: queryParameters, + options: Options( + 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; + 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`. @@ -358,9 +396,27 @@ 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. + /// + /// 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() ?? ''; + if (accept.contains('image/')) return true; + final query = options.uri.queryParameters; + return query['id'] == 'pokemon:pokemon' && query['endpoint'] == 'battle' && query['action'] == 'recover'; + } + @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; + if (err.type == DioExceptionType.badResponse && status == 404 && _isExpectedMissing(err.requestOptions)) { + 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 +528,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..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 @@ -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,30 @@ abstract class _AndroidHttpMethodChannel { return null; } } + + /// Post a raw JSON body. + static Future<_KotlinHttpResponse?> _postJson({ + 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; + } + return _KotlinHttpResponse._fromRawResp(resp); + } on PlatformException catch (e, st) { + talker.handle(e, st, '[KtHttp] POST json failed on $url'); + return null; + } + } } /// The http response. @@ -155,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) { @@ -171,6 +198,12 @@ final class KotlinHttpClient { headers: headers ?? {}, body: body as Map, ), + 'application/json' => await _AndroidHttpMethodChannel._postJson( + url: url.toString(), + headers: headers ?? {}, + body: body as String, + singleAttempt: singleAttempt, + ), final v => throw UnsupportedError('unsupported content type: $v'), }; if (rawResp == null) { 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( diff --git a/lib/utils/show_toast.dart b/lib/utils/show_toast.dart index 9d31e39d..f1df0041 100644 --- a/lib/utils/show_toast.dart +++ b/lib/utils/show_toast.dart @@ -43,15 +43,25 @@ 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(); } } 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..0c6ff162 --- /dev/null +++ b/test/regression/test_180_pokemon_party_healing_test.dart @@ -0,0 +1,250 @@ +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'); + }); + + 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 +/// 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..839a5f78 --- /dev/null +++ b/test/regression/test_181_pokemon_cubit_lifecycle_test.dart @@ -0,0 +1,501 @@ +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/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'; +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); [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, + }) { + 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 == '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; + return jsonEncode({ + 'success': true, + 'data': {'message': '已设置', 'hidden': serverStatusBarHidden}, + }); + } + if (action == 'profile') { + return jsonEncode({ + 'success': true, + '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, + '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); + } + 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)); + 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(AdventureCache()) + ..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; 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); + }); + + 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); + }); + + 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); + }); + + 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'); + }); + + 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); + }); + + 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)); + }); +} 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..adddaf7b --- /dev/null +++ b/test/regression/test_182_pokemon_pure_helpers_test.dart @@ -0,0 +1,107 @@ +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 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()); + + // 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); + }); +} 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); + }); +}