diff --git a/.claude/skills/finalize-release/SKILL.md b/.claude/skills/finalize-release/SKILL.md index 7d25f74b9c..9c0d9bbb31 100644 --- a/.claude/skills/finalize-release/SKILL.md +++ b/.claude/skills/finalize-release/SKILL.md @@ -126,6 +126,8 @@ sync 側の走行は **必須**。master→dev 差分が 0 件の場合のみ自 sync が **スキップ**(差分 0 件)された場合はこの手順を飛ばす。新規作成・流用いずれかで dev<-master PR が存在する場合のみ実行する。**緩和→マージ→復元は 1 つの bash 呼び出しにまとめ、`set +e` でマージ失敗を握って復元を無条件実行する**(途中で関数が分かれて復元が飛ぶ事故を防ぐ)。 + **マージ前にコンフリクトを確認する。** `gh pr view --json mergeable,mergeStateStatus` が `CONFLICTING` を返す場合、`master` から特定コミットだけを cherry-pick したリリース(`create-release-pr` の「この変更だけ」指定など)の後に起きる **版数ファイルのねじれ** が典型。このときは Ruleset 緩和より先に `sync-dev-from-master` の「版数ファイルのコンフリクト解決」に従って版数を解決し(本番版数の判断はユーザー承認を取る)、PR を `MERGEABLE` にしてから 6-3 以降へ進む。`mergeable` はプッシュ直後に非同期で古い値を返すことがあるため、`git merge-base --is-ancestor origin/dev origin/chore/dev-from-master` で構造的な包含も確認するとよい。 + 1. マージ対象 PR 番号を確定(手順 5 で新規作成 or 流用した PR)。ローカルが `chore/dev-from-master` に居るとブランチ削除で支障が出るため `git switch dev`(または安全な枝)へ退避する。 2. `OWNER_REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)` を解決。プレフライトの「マージ方式制約」記録を使う: - dev が `merge` を許可済み → **緩和不要**。そのまま手順 6-5 のマージへ。 diff --git a/.claude/skills/sync-dev-from-master/SKILL.md b/.claude/skills/sync-dev-from-master/SKILL.md index 8d3d55105a..cf8febf601 100644 --- a/.claude/skills/sync-dev-from-master/SKILL.md +++ b/.claude/skills/sync-dev-from-master/SKILL.md @@ -75,6 +75,7 @@ description: Open a dev<-master merge PR that syncs master back into dev after a - 何もコミットは積まない(master 先端そのまま)。biome 等のフォーマッタは走らせない(新規コミット無し)。 - push 前に、対象 SHA(`git rev-parse origin/master`)・取り込まれるコミット件数・本文に入れる version をユーザーに提示して承認を取る。 + - **例外**: この PR が版数ファイルで衝突する場合(`master` から特定 PR だけ cherry-pick したリリースの後に起きる。後述の「版数ファイルのコンフリクト解決」を参照)は、この枝に `origin/dev` をマージして解決コミットを 1 つだけ積む。それ以外は master 先端そのまま。 5. **PR 本文を組み立て(テンプレ厳守・全節を実内容で埋める)** @@ -160,6 +161,56 @@ description: Open a dev<-master merge PR that syncs master back into dev after a - テスト欄のチェック状態(全 OFF + 説明文あり) - 使用した `release_version`(どこから取ったか) +## 版数ファイルのコンフリクト解決(cherry-pick / hotfix リリース後) + +通常の「dev から丸ごと」リリースでは `master` が `dev` の完全な祖先になるため、この同期 PR は衝突しない(手順 4 のとおり master 先端そのままで済む)。しかし **リリースブランチを `master` から切って特定 PR だけ cherry-pick したリリース**(`create-release-pr` に「この変更だけ」と指定したホットフィックス型など)では、`dev` を `master` に取り込んでいないため、`master` のリリース版数と `dev` の canary bump 版数が **ねじれたまま** 残り、この同期 PR が版数ファイルで衝突する。 + +衝突するのは版数ファイルのみで、アプリコードは衝突しない: + +- `android/app/build.gradle`(`versionCode` / `versionName`) +- `app.config.ts`(`version` / `buildNumber` / `versionCode`) +- `ios/TrainLCD.xcodeproj/project.pbxproj`(`MARKETING_VERSION` / `CURRENT_PROJECT_VERSION`) + +### 解決方針: semver はリリース版、ビルド番号は最大値 + +| 種別 | 採用する側 | 理由 | +| ---- | ---- | ---- | +| semver(`version` / `versionName` / `MARKETING_VERSION`) | `master`(リリース版数) | `dev` をリリース済みバージョンへ前進させる。過去 PR #6396 も semver 競合をリリース版で解決している | +| ビルド番号(`versionCode` / `CURRENT_PROJECT_VERSION` / `buildNumber`) | `max(dev, master)`(通常は `dev` 側が大きい) | ストアはビルド番号の単調増加を要求する。canary で既発行の番号より下げると次回 bump で衝突する | + +### 解決手順 + +1. `chore/dev-from-master`(= master 先端)に居る状態で `origin/dev` をマージする(手順 4 の「コミットを積まない」原則の唯一の例外): + + ```bash + git switch chore/dev-from-master + git merge --no-ff --no-commit origin/dev + ``` + +2. 衝突した版数 3 ファイルを master 側(`--ours`)で確定してから、ビルド番号だけ `dev` 側の最大値へ引き上げる(下は master=530/2743・dev=531/2744 の例): + + ```bash + git checkout --ours android/app/build.gradle app.config.ts ios/TrainLCD.xcodeproj/project.pbxproj + sed -i 's/versionCode 100000530/versionCode 100000531/g' android/app/build.gradle + sed -i "s/buildNumber: '2743'/buildNumber: '2744'/g; s/versionCode: 100000530/versionCode: 100000531/g" app.config.ts + sed -i 's/CURRENT_PROJECT_VERSION = 2743;/CURRENT_PROJECT_VERSION = 2744;/g' ios/TrainLCD.xcodeproj/project.pbxproj + ``` + + これらの版数ファイルは master↔dev で数値以外の差分が無い(`git diff origin/dev origin/master -- ` で確認できる)ため、`--ours` で master を採ってもコンテンツは失われない。 + +3. **`dev` への正味の変化が semver だけ**(ビルド番号は据え置き)であることを確認してからマージコミットを作成し push する: + + ```bash + git add android/app/build.gradle app.config.ts ios/TrainLCD.xcodeproj/project.pbxproj + git diff origin/dev HEAD # semver(例 10.9.0 -> 10.9.1)のみが出るのが正 + git commit -m "origin/dev をマージし版数競合を解決(semver=、ビルド番号=)" + git push origin chore/dev-from-master + ``` + +4. 以降は通常どおり merge commit でマージする(`finalize-release` が Ruleset 一時緩和つきで実行する)。マージ後は dev HEAD が 2 親の merge commit になり、`git rev-list --count origin/dev..origin/master` が `0`(dev が master を完全包含)になることを検証する。 + +**semver をリリース版数へ更新する判断とビルド番号の採用値は本番の版数に関わるため、自動で確定せずユーザーに確認する。** + ## 注意事項 - **Squash merge 禁止**。これがこのスキルの存在理由の半分。実行時と完了報告で二重に明示する。 diff --git a/AGENTS.md b/AGENTS.md index 44d61243c1..0cd2e93796 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,7 @@ This handbook defines how automation agents collaborate safely and effectively o - Biome is authoritative; avoid `// biome-ignore` unless a rule is truly incompatible and document the rationale inline. - Components → PascalCase (`StationBanner.tsx`); hooks → `use*` (`useStationFeed.ts`); Jotai atoms → `store/atoms/*.ts`; GraphQL operations → `FeatureVerbQuery`. - Jotai state is held in field-level primitive atoms (named exports such as `arrivedAtom`, `headerStateAtom`). Always subscribe to those for reads; the default-exported `stationState` / `navigationState` / `lineState` are write-compatible facades and subscribing to them re-renders on every field change. See `docs/state-management.md`. +- Void side-effect hooks that subscribe to high-frequency atoms (`locationAtom` updates every second while riding) must not be called in a screen component's body. Host them in a renderless effects component instead (`MainScreenEffects` in `src/screens/Main.tsx`, `PermittedLayoutEffects` in `src/components/Permitted.tsx`; one hook per `Fx*` component so per-hook render cost stays measurable). Gate platform- or setting-specific hooks by conditionally mounting their host (`FxTTS`, `FxUpdateLiveActivities`). For objects with high-frequency fields such as `pictureInPictureAtom.activityState`, subscribe the narrow derived atoms (`pictureInPictureEnabledAtom` / `pictureInPictureActiveAtom`) instead of the whole atom. Details in `docs/state-management.md`. - Co-locate style modules or constants near their consumers; share cross-cutting utilities through `src/utils/`. - Keep comments purposeful: explain intent or non-obvious constraints, not obvious mechanics. diff --git a/android/app/build.gradle b/android/app/build.gradle index bf613294e7..2848ed7b25 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -143,13 +143,13 @@ android { dimension "environment" applicationId "me.tinykitten.trainlcd.dev" versionNameSuffix "-dev" - versionCode 100000530 - versionName "10.9.1" + versionCode 100000537 + versionName "10.10.0" } prod { dimension "environment" - versionCode 100000530 - versionName "10.9.1" + versionCode 100000537 + versionName "10.10.0" } } } diff --git a/app.config.ts b/app.config.ts index 83a5097028..0825d7e302 100644 --- a/app.config.ts +++ b/app.config.ts @@ -3,7 +3,7 @@ const IS_DEV = process.env.APP_VARIANT === 'dev'; export default { name: 'TrainLCD', slug: 'trainlcd', - version: '10.9.1', + version: '10.10.0', plugins: [ 'expo-image', 'expo-font', @@ -52,7 +52,7 @@ export default { }, }, ios: { - buildNumber: '2743', + buildNumber: '2750', scheme: IS_DEV ? 'CanaryTrainLCD' : 'ProdTrainLCD', bundleIdentifier: IS_DEV ? 'me.tinykitten.trainlcd.dev' : 'me.tinykitten.trainlcd', supportsTablet: true, @@ -60,7 +60,7 @@ export default { android: { package: IS_DEV ? 'me.tinykitten.trainlcd.dev' : 'me.tinykitten.trainlcd', permissions: [], - versionCode: 100000530, + versionCode: 100000537, }, owner: 'trainlcd', experiments: { diff --git a/assets/translations/en.json b/assets/translations/en.json index a8ce712000..6bf1c245d4 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -124,6 +124,9 @@ "ttsAlertText": "The automatic announcement feature does not work in silent mode or when the Internet is not connected. Please be careful.", "bgTtsAlertText": "When the background audio feature is enabled, sound will play even when silent mode is on. Please be especially mindful when on a train.", "bgTtsAppClipAlertText": "To use the background audio feature, please download the full version.", + "ttsFeatureDisabledText": "The automatic announcement feature is temporarily unavailable. Please check the Service Status page below for the latest availability.", + "serviceStatus": "Service Status", + "failedToOpenLink": "Failed to open the link. Please try again later.", "weekdayNotice": "Weekday transit stations are included. The corresponding station will be displayed as a passage. Please be careful.", "holidayNotice": "Includes holiday transit stations. The corresponding station will be displayed as a passage. Please Be careful.", "shareNotice": "Press and hold the screen for easy sharing😊.", @@ -329,9 +332,8 @@ "portraitModeTitle": "Portrait Mode", "portraitModeDescription": "When enabled, the running screen switches to a layout optimized for portrait orientation while you hold your device upright.", "telemetryDescription": "Your device's geographic coordinates are sent to our analytics server. The information is used only for analytics.", - "etaAssistTitle": "Improve arrival detection", - "etaAssistDescription": "When enabled, in sections where GPS accuracy drops such as subways, the app uses the server's estimated arrival times (ETA) to assist arrival detection. GPS remains the source of truth for arrival and current location; ETA is only an aid.", + "batterySettings": "Battery", "powerSavingLocationTitle": "Power-saving location mode", - "powerSavingLocationDescription": "When enabled, the app requests location updates less often and allows tracking to pause while stopped while retaining the high accuracy needed for station detection, reducing battery drain and heat on long rides. On Android, removing the app from the Recents screen stops the foreground location service and its persistent notification, but does not guarantee that the location task itself is unregistered. This works on both Android and iOS and turns on automatically while your device is in low-power mode. If arrival notices become delayed, also turn off the device's low-power mode.", + "powerSavingLocationDescription": "When enabled, location accuracy is lowered to a battery-friendly level and iOS pauses tracking automatically while you are stopped, further reducing battery drain and heat on long rides. The lower accuracy may delay or shift station detection and arrival announcements. The relaxed update frequency is already part of the default settings. It also turns on automatically while your device is in low-power mode.", "passStationLabel": "Pass" } diff --git a/assets/translations/ja.json b/assets/translations/ja.json index 454aec181f..335f26277a 100644 --- a/assets/translations/ja.json +++ b/assets/translations/ja.json @@ -125,6 +125,9 @@ "ttsAlertText": "自動アナウンス機能はマナーモードもしくはインターネット未接続状態では動作いたしません。ご注意ください。", "bgTtsAlertText": "バックグラウンド音声機能を有効にすると、マナーモード設定中でも音声が流れます。特に電車内ではご注意ください。", "bgTtsAppClipAlertText": "バックグラウンド音声機能をご利用になるには完全版をダウンロードしてください。", + "ttsFeatureDisabledText": "自動アナウンス機能は現在一時的にご利用いただけません。最新の稼働状況は下記のサービスステータスをご確認ください。", + "serviceStatus": "サービスステータス", + "failedToOpenLink": "リンクを開けませんでした。しばらく経ってからもう一度お試しください。", "weekdayNotice": "平日通過の駅が含まれています。該当の駅は通過表示となります。ご注意ください。", "holidayNotice": "休日通過の駅が含まれています。該当の駅は通過表示となります。ご注意ください。", "shareNotice": "画面を長押しすると簡単にシェアができます😊", @@ -330,9 +333,8 @@ "portraitModeTitle": "ポートレートモード", "portraitModeDescription": "有効にすると、走行画面で端末を縦向きにした際に、縦画面に最適化されたデザインで表示します。", "telemetryDescription": "お使いの端末の地理的座標を解析用サーバに送信します。送信された情報は解析以外に使用されません。", - "etaAssistTitle": "到着判定の改善", - "etaAssistDescription": "有効にすると、地下鉄などGPSの精度が落ちる区間で、サーバーの到着予測(ETA)を使って到着判定を補助します。到着や現在地はGPSが基準で、ETAはあくまで補助です。", + "batterySettings": "バッテリー", "powerSavingLocationTitle": "省電力測位モード", - "powerSavingLocationDescription": "有効にすると、駅判定に必要な高精度測位を維持しながら位置情報の更新頻度を抑え、停車中は測位を自動休止して、長時間の乗車での電池消費と発熱を減らします。Androidでは履歴画面からアプリを削除した際に、位置情報のフォアグラウンドサービスと常駐通知を停止しますが、測位タスク自体の登録解除を保証するものではありません。AndroidとiOSの両方で動作し、端末の省電力モード中は自動的に有効になります。到着案内が遅れる場合は端末の省電力モードも解除してください。", + "powerSavingLocationDescription": "有効にすると、測位精度を電池優先まで下げ、停車中はiOSが測位を自動休止して、長時間の乗車での電池消費と発熱をさらに減らします。精度の低下により駅の判定や到着案内が遅れたりずれたりする場合があります。位置情報の更新頻度の緩和は標準設定に組み込まれています。端末の省電力モード中は自動的に有効になります。", "passStationLabel": "通過" } diff --git a/docs/README.md b/docs/README.md index 257f2f5f92..a1d1330732 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,5 +5,6 @@ ## ドキュメント一覧 - [状態管理ガイドライン (Jotai)](./state-management.md) +- [Apollo Client → TanStack Query 移行メモ](./apollo-to-react-query-migration.md) - [canary リリース PR でのバージョン更新](./bump-version-on-canary-pr.md) - [本番リリース PR でのバージョン更新](./bump-version-on-release-pr.md) diff --git a/docs/apollo-to-react-query-migration.md b/docs/apollo-to-react-query-migration.md new file mode 100644 index 0000000000..f5c0973db5 --- /dev/null +++ b/docs/apollo-to-react-query-migration.md @@ -0,0 +1,190 @@ +# Apollo Client → TanStack Query 移行メモ + +このアプリの GraphQL 通信レイヤーは、かつて Apollo Client を使っていたが、 +現在は **TanStack Query (React Query) + graphql-request** に置き換えられている +(移行 PR: #6210)。この文書は、移行時に何を「移行しなかった」のか、そして +なぜ体感で大きく高速化したのかを記録として残すものである。 + +## TL;DR + +- キャッシュ戦略は 1:1 で移植したのではなく、React Query のモデルに合わせて + **再設計した**。 +- Apollo 時代の「キャッシュ逃れ」チューニング(`typePolicies` の + `keyFields: false`)は**移植していない**。React Query はエンティティ正規化を + 行わないため、その回避策が解決していた問題は構造的に起きず、設定ごと不要に + なった。 +- 体感の高速化は「軽微」ではなく大きかった。理由は、TrainLCD のレスポンスが + **深くネストした巨大なオブジェクトグラフ**であり、Apollo の正規化・差分検知が + そのグラフを毎回何周も走査する同期 JS 処理を、React Native の**単一 JS + スレッド**(ジェスチャー・遷移と同じスレッド)上で行っていたため。React Query + ではこの走査コストが丸ごと消えた。 + +## 移行前後のキャッシュ構成 + +### Apollo 時代 (`src/lib/gql.ts`, 移行前) + +```typescript +export const gqlClient = new ApolloClient({ + link: new HttpLink({ uri }), + cache: new InMemoryCache({ + typePolicies: { + LineNested: { keyFields: false }, + StationNested: { keyFields: false }, + TrainTypeNested: { keyFields: false }, + TrainType: { keyFields: false }, + Station: { keyFields: false }, + }, + }), +}); +``` + +`keyFields: false` は「これらの型を**正規化(normalization)の対象から外す**」 +指定である。Apollo は既定では `id`/`__typename` で全エンティティを正規化し、 +クエリをまたいでマージ・重複排除する。しかし TrainLCD の駅・路線データは +「同じ ID でも文脈によって中身が異なる」ケースがあり、正規化されると別物が +誤ってマージされて壊れる。それを避けるために正規化を切っていた。 + +副作用として、正規化を切るとクエリ横断のキャッシュ再利用(重複排除)が効かなく +なるため、「同じエンティティを含む別クエリはそれぞれ通信が走る = API 通信が +増える」挙動になっていた。これが当時の「キャッシュ逃れでパフォーマンスを +犠牲にして通信を増やすチューニング」の実体である。 + +### React Query 移行後 (`src/lib/gql.ts`, 現行) + +```typescript +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Number.POSITIVE_INFINITY, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }, + }, +}); +``` + +- クエリキーは `[オペレーション名, variables]`(`graphqlQueryKey`)。 +- React Query は**エンティティ正規化を一切行わない**。クエリ結果まるごとを + `(operationName, variables)` 単位でキャッシュするだけなので、当時 + `keyFields: false` で潰していた「同一 ID の別エンティティが誤マージされる」 + 問題は発生し得ない。 +- 既定方針は `staleTime: Infinity` の cache-first。Apollo の既定 + (cache-first) と通信挙動は等価になる。 + +### 強制再取得したい箇所 + +Apollo 時代に「キャッシュを使わず最新を取りたい」箇所は、移行後は**明示的に +キーを破棄してから取り直す**方式に置き換えた。 + +- `src/screens/RouteSearchScreen.tsx` — `queryClient.removeQueries({...})` で + `GET_LINE_GROUP_STATIONS` のキーを破棄してから再フェッチ。 +- `src/hooks/useInitialNearbyStation.ts` — `refetch` は常に新鮮な位置情報で + 取り直す。 + +### 互換フック + +呼び出し側の書き換えを最小化するため、Apollo の API 互換フックを用意した。 + +- `useGraphQLQuery`(`src/hooks/useGraphQLQuery.ts`) — `useQuery` 互換の宣言的 + フェッチ。 +- `useLazyGraphQLQuery`(`src/hooks/useLazyGraphQLQuery.ts`) — `useLazyQuery` + 互換の命令的フェッチ。エラー時も reject せず `{ data, error }` で解決する。 +- `gqlClient.query`(`src/lib/gql.ts`) — `client.query` 互換のファサード。 + +## なぜ体感が飛躍的に速くなったのか + +### 前提: レスポンスが「異常に大きい入れ子グラフ」 + +`src/lib/graphql/queries.ts` の `StationFields` を見ると、1 駅オブジェクトが +深くネストしている。 + +- `Station` → `line` / `lines[]`(各 `LineInStation`: company・lineSymbols・ + station・stationNumbers・nameTtsSegments …) +- さらに `Station.trainType` → `TrainTypeNested` → その中に `line` と `lines[]` + があり、それぞれがフル `LineNestedFields`(さらにその中に `trainType` …) + +`GetLineGroupStations` / `GetLineStations` / `GetStationsByName` は、これを +**配列で N 件**返す。路線によっては数十〜百駅。実体は巨大なオブジェクトグラフ +である。 + +### Apollo だと重かった処理 + +Apollo の `InMemoryCache` は、1 回のクエリ結果ごとに**グラフ全体を何度も走査 +する同期 JS 処理**を行う。`keyFields: false` で消えるのは「正規化キーの付与」 +だけで、走査コスト自体は残る。 + +1. **書き込み時の全走査 (writeQuery)** — ネスト全要素をキャッシュへ書くため、 + グラフを再帰的に 1 周。 +2. **`__typename` の自動注入** — 全選択セットに `__typename` を足すため、通信 + ペイロードもネスト分だけ膨らみ、`JSON.parse` するバイト数も増える。 +3. **dev 時の再帰的 `Object.freeze`** — 返す結果を深く凍結。巨大グラフを丸ごと + freeze する分でもう 1 周。 +4. **broadcastQueries(差分検知)** — 書き込みのたびに、アクティブな全 + `useQuery` を `cache.diff` で再評価して通知要否を判定。観測者の数 × グラフ + サイズでさらに走査。 +5. **読み出し時の再構築** — キャッシュから返すときも結果オブジェクトを組み立て + 直すため、もう 1 周。 + +合計すると「巨大グラフを数周ぶん走査 + freeze + 差分検知」が、1 レスポンスごとに +同期的に走る。React Native ではこれらがすべて**単一 JS スレッド**上で動き、その +スレッドはタッチ・ジェスチャー・画面遷移・JS 側アニメーションと同じである。駅 +一覧を取るたびに JS スレッドが占有され、その間タップや遷移が引っかかる。これが +「操作のレスポンスが悪い」の正体だった。 + +### React Query に変えて消えたコスト + +現行の経路は実質これだけである。 + +```text +fetch() → response.json()(1回の parse)→ 参照を queryKey で保存 → そのまま component へ +``` + +- **正規化なし**: エンティティ分解・キー付与・マージという Apollo 由来の + グラフ走査が消滅し、書き込みはクエリキー単位の参照保存になった。ただし + React Query 既定の `structuralSharing` により、既存キャッシュを更新する + フェッチでは JSON 互換データの再帰比較(不変部分の参照再利用)が 1 周残る + (キャッシュが空の初回取得では走らない)。 +- **freeze なし / 差分 broadcast なし**: observer 数に依存した再走査が消滅。 +- **`__typename` 注入なし**: ペイロードが小さくなり `JSON.parse` も軽い。 +- **読み出しは参照を返すだけ**: 再構築の 1 周も消滅。 + +1 操作あたりの JS 処理が「グラフを数周」から「parse 1 回 + 参照保存(既存キャッ +シュの更新時のみ structuralSharing の比較 1 周)」に落ちた。 +このコスト差は**ペイロードが大きいほど開く**ため、いちばん重い駅一覧・検索結果 +でいちばん効く = ユーザーが詰まりを感じていたまさにその場所が解消された。 + +### 「軽微」と見積もった原因(反省) + +移行時は「データ取得ライブラリの差し替え、cache-first は cache-first のまま、 +振る舞いは等価」というインフラ等価交換の枠で評価していた。見落としたのは次の +3 点の掛け算である。 + +- Apollo の正規化コストは**ペイロード形状に比例**して効く。 +- TrainLCD のペイロードは例外的に大きい深いネストグラフである。 +- それが**単一 JS スレッドの React Native でジェスチャーと同じ土俵**に乗って + いる。 + +計算量(big-O)は変わらないので「軽微」と表現したが、実際は**定数項が桁違い**で、 +しかもそれが体感の支配項だった。典型的な「定数項を軽視した見積もりミス」である。 + +### 補足(副次的な寄与・主因ではない) + +- `@apollo/client` と関連依存の除去で JS バンドルが縮み、起動時 parse は軽く + なる。ただしこれは**起動時**の話で、操作レスポンスの主因ではない。 +- Apollo のリアクティブ層(observable / reactive vars)の常時オーバーヘッドも + 消えているが、本体は上記の走査コストである。 + +## まとめ + +| 項目 | Apollo 時代 | React Query 移行後 | +| --- | --- | --- | +| キャッシュ単位 | エンティティ正規化キャッシュ | クエリ結果単位 `(op 名, variables)` | +| 「キャッシュ逃れ」設定 | `keyFields: false` で正規化を無効化 | 不要(正規化機構自体がない) | +| 既定方針 | cache-first | `staleTime: Infinity` の cache-first(等価) | +| 強制再取得 | fetchPolicy 等 | `queryClient.removeQueries` で明示破棄 | +| 1 操作あたりの JS コスト | グラフを数周走査 + freeze + 差分検知 | parse 1 回 + 参照保存(更新時は再帰比較 1 周) | + +TrainLCD は元々(`keyFields: false` で)Apollo の正規化の恩恵をほぼ受けておらず、 +**走査コストだけ全額払っている**状態だった。React Query への移行は、その払い損 +だったコストを丸ごと外したことになる。これが体感高速化の本質である。 diff --git a/docs/state-management.md b/docs/state-management.md index 339d82d224..c6d03894a9 100644 --- a/docs/state-management.md +++ b/docs/state-management.md @@ -75,6 +75,41 @@ React 外部 (TaskManager のコールバック等) からの write 関数 (フィールド単位の `Object.is` 比較と `set`) に同じフィールド を追加する +### 高頻度更新フィールドを含むオブジェクト atom は narrow な派生 atom で購読する + +`pictureInPictureAtom` の `activityState` は位置更新のたび (走行中は毎秒) +新オブジェクトへ差し替わる。`enabled` / `active` だけが必要な購読者が +atom を丸ごと購読すると毎ティック再レンダーされるため、boolean の派生 atom +(`pictureInPictureEnabledAtom` / `pictureInPictureActiveAtom`) を購読する。 +派生 atom は算出値が `Object.is` で同一な限り購読者へ通知しない。 + +```typescript +// ❌ activityState の毎秒更新に巻き込まれる +const { active } = useAtomValue(pictureInPictureAtom); + +// ✅ active の実際の変化時のみ再レンダー +const active = useAtomValue(pictureInPictureActiveAtom); +``` + +### 高頻度 atom を購読する副作用フックは renderless ホストに隔離する + +`locationAtom` (走行中は毎秒更新) などを購読する返り値なしの副作用フックを +画面コンポーネント本体で呼ぶと、位置更新のたびに画面全体の render 関数が +再実行される。こうしたフックは `null` を返すだけの renderless コンポーネント +に隔離し、画面はそれをマウントするだけにする。 + +- 実例: `src/screens/Main.tsx` の `MainScreenEffects` (`Fx*` コンポーネント群)、 + `src/components/Permitted.tsx` の `PermittedLayoutEffects` +- 1 フック = 1 コンポーネント (`FxRefreshStation` など) に分割してあるのは、 + プロファイル時に React DevTools 上でフック単位の再レンダー回数・コストを + 計測可能にするため。新しい常駐フックを足すときも同じ形式で追加する +- 設定・プラットフォームで不要になるフックは条件付きマウントで丸ごと止める。 + 実例: `FxTTS` (ユーザー設定と Remote Config キルスイッチの両方が有効なときのみ。 + 単体テストできるよう本体は `src/components/FxTTS.tsx` に定義)、 + `FxUpdateLiveActivities` (iOS のみ)、 + `Permitted.tsx` のウェアラブル連携 (OS 別)。フックを条件分岐で呼ぶことは + できないが、ホストコンポーネントのマウント自体を条件にすれば安全に止められる + ## テストでのモック `useAtomValue` をモックする場合、フックやコンポーネントはフィールド atom diff --git a/ios/TrainLCD.xcodeproj/project.pbxproj b/ios/TrainLCD.xcodeproj/project.pbxproj index 969554b862..e25131c8fb 100644 --- a/ios/TrainLCD.xcodeproj/project.pbxproj +++ b/ios/TrainLCD.xcodeproj/project.pbxproj @@ -2407,7 +2407,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2743; + CURRENT_PROJECT_VERSION = 2750; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; @@ -2446,7 +2446,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2743; + CURRENT_PROJECT_VERSION = 2750; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = TrainLCD; @@ -2505,7 +2505,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2743; + CURRENT_PROJECT_VERSION = 2750; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -2561,7 +2561,7 @@ "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", "\"$(inherited)\"", ); - MARKETING_VERSION = 10.9.1; + MARKETING_VERSION = 10.10.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; @@ -2611,7 +2611,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = YES; - CURRENT_PROJECT_VERSION = 2743; + CURRENT_PROJECT_VERSION = 2750; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -2663,7 +2663,7 @@ "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", "\"$(inherited)\"", ); - MARKETING_VERSION = 10.9.1; + MARKETING_VERSION = 10.10.0; MTL_ENABLE_DEBUG_INFO = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(inherited)"; @@ -2690,7 +2690,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2743; + CURRENT_PROJECT_VERSION = 2750; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -2729,7 +2729,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2743; + CURRENT_PROJECT_VERSION = 2750; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -2940,7 +2940,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/CanaryRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2743; + CURRENT_PROJECT_VERSION = 2750; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -2991,7 +2991,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2743; + CURRENT_PROJECT_VERSION = 2750; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3042,7 +3042,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/ProdWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2743; + CURRENT_PROJECT_VERSION = 2750; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3100,7 +3100,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2743; + CURRENT_PROJECT_VERSION = 2750; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3151,7 +3151,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/CanaryWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2743; + CURRENT_PROJECT_VERSION = 2750; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3208,7 +3208,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2743; + CURRENT_PROJECT_VERSION = 2750; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3256,7 +3256,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/ProdRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2743; + CURRENT_PROJECT_VERSION = 2750; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3307,7 +3307,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2743; + CURRENT_PROJECT_VERSION = 2750; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3526,7 +3526,7 @@ CODE_SIGN_ENTITLEMENTS = ProdAppClip/ProdAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2743; + CURRENT_PROJECT_VERSION = 2750; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3582,7 +3582,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2743; + CURRENT_PROJECT_VERSION = 2750; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3632,7 +3632,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryAppClip/CanaryAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2743; + CURRENT_PROJECT_VERSION = 2750; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3656,7 +3656,7 @@ "@executable_path/Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 10.9.1; + MARKETING_VERSION = 10.10.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; @@ -3690,7 +3690,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2743; + CURRENT_PROJECT_VERSION = 2750; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3710,7 +3710,7 @@ "@executable_path/Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 10.9.1; + MARKETING_VERSION = 10.10.0; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PODS_ROOT = "${SRCROOT}/Pods"; diff --git a/package-lock.json b/package-lock.json index 47e24c03df..9a86a286ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "trainlcd", - "version": "10.9.1", + "version": "10.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trainlcd", - "version": "10.9.1", + "version": "10.10.0", "hasInstallScript": true, "dependencies": { "@expo-google-fonts/roboto": "^0.2.3", diff --git a/package.json b/package.json index 2c569a140a..0904ef776c 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,9 @@ "web": "expo start --web", "lint": "biome check ./src", "format": "biome format ./src --write", - "test": "TZ=UTC jest", + "test": "cross-env TZ=UTC jest", "typecheck": "tsc --noEmit", - "watch:test": "TZ=UTC jest --watch", + "watch:test": "cross-env TZ=UTC jest --watch", "gql:codegen": "graphql-codegen --config utils/codegen.ts", "version:bump": "node scripts/bump-version.js", "postinstall": "patch-package" @@ -167,5 +167,5 @@ } }, "name": "trainlcd", - "version": "10.9.1" + "version": "10.10.0" } diff --git a/src/components/CommonCard.tsx b/src/components/CommonCard.tsx index cba83af683..879be1ad85 100644 --- a/src/components/CommonCard.tsx +++ b/src/components/CommonCard.tsx @@ -74,21 +74,6 @@ const styles = StyleSheet.create({ marginRight: 12, transform: [{ scale: 0.5 }], }, - numberingIconContainer: { - flex: 1, - alignItems: 'center', - transformOrigin: 'top', - transform: [ - { - scale: 0.5, - }, - ], - }, - markPlaceholder: { - width: isTablet ? 52.5 : 35, - height: isTablet ? 52.5 : 35, - marginRight: 12, - }, texts: { flex: 1, }, @@ -157,6 +142,8 @@ const styles = StyleSheet.create({ const PAREN_GROUP_REGEX = /([((][^))]*[))])/; const PAREN_WRAPPED_REGEX = /^[((][^))]*[))]$/; +const EMPTY_STATIONS: Station[] = []; + type SubtitleProps = { inboundText: string; outboundText: string; @@ -208,7 +195,7 @@ const AnimatedCardChevron = Animated.createAnimatedComponent(View); export const CommonCard: React.FC = ({ line, targetStation, - stations = [], + stations = EMPTY_STATIONS, title, hideParens, shrinkBoundAffix, diff --git a/src/components/FxTTS.test.tsx b/src/components/FxTTS.test.tsx new file mode 100644 index 0000000000..bb8e40cbd2 --- /dev/null +++ b/src/components/FxTTS.test.tsx @@ -0,0 +1,110 @@ +import { act, render } from '@testing-library/react-native'; +import { createStore, Provider } from 'jotai'; +import { useTTS } from '~/hooks/useTTS'; +import { isTTSFeatureEnabled } from '~/lib/remoteConfig'; +import speechState from '~/store/atoms/speech'; +import { FxTTS } from './FxTTS'; + +jest.mock('~/utils/isDevApp', () => ({ + isDevApp: false, +})); + +jest.mock('~/hooks/useTTS', () => ({ + useTTS: jest.fn(), +})); + +// useTTSFeatureEnabled(useSyncExternalStore) が購読するリスナーを捕捉し、 +// Remote Config 取得完了(キャッシュ更新)をテストから擬似的に発火できるようにする。 +const mockRemoteConfigListeners = new Set<() => void>(); +jest.mock('~/lib/remoteConfig', () => ({ + isTTSFeatureEnabled: jest.fn(() => true), + subscribeRemoteConfig: jest.fn((listener: () => void) => { + mockRemoteConfigListeners.add(listener); + return () => { + mockRemoteConfigListeners.delete(listener); + }; + }), +})); + +const mockedIsTTSFeatureEnabled = jest.mocked(isTTSFeatureEnabled); +const mockedUseTTS = jest.mocked(useTTS); + +const emitRemoteConfigUpdate = () => { + act(() => { + for (const listener of mockRemoteConfigListeners) { + listener(); + } + }); +}; + +const renderFxTTS = (enabled: boolean) => { + const store = createStore(); + store.set(speechState, { + enabled, + backgroundEnabled: false, + ttsEnabledLanguages: ['JA', 'EN'], + monetizedPlanEnabled: false, + }); + + return render( + + + + ); +}; + +describe('FxTTS', () => { + afterEach(() => { + jest.clearAllMocks(); + mockRemoteConfigListeners.clear(); + }); + + it.each` + userEnabled | featureEnabled | shouldMount + ${true} | ${true} | ${true} + ${true} | ${false} | ${false} + ${false} | ${true} | ${false} + ${false} | ${false} | ${false} + `( + 'ユーザー設定=$userEnabled / Remote Config=$featureEnabled のときマウント=$shouldMount', + ({ userEnabled, featureEnabled, shouldMount }) => { + mockedIsTTSFeatureEnabled.mockReturnValue(featureEnabled); + + renderFxTTS(userEnabled); + + if (shouldMount) { + expect(mockedUseTTS).toHaveBeenCalled(); + } else { + expect(mockedUseTTS).not.toHaveBeenCalled(); + } + } + ); + + it('マウント後にRemote Configで無効化されるとアンマウントされる', () => { + mockedIsTTSFeatureEnabled.mockReturnValue(true); + + renderFxTTS(true); + + expect(mockedUseTTS).toHaveBeenCalled(); + mockedUseTTS.mockClear(); + + // 起動時の非同期取得が後から tts_enabled=false を返したケースを再現する + mockedIsTTSFeatureEnabled.mockReturnValue(false); + emitRemoteConfigUpdate(); + + expect(mockedUseTTS).not.toHaveBeenCalled(); + }); + + it('マウント後にRemote Configで有効化されるとマウントされる', () => { + mockedIsTTSFeatureEnabled.mockReturnValue(false); + + renderFxTTS(true); + + expect(mockedUseTTS).not.toHaveBeenCalled(); + + mockedIsTTSFeatureEnabled.mockReturnValue(true); + emitRemoteConfigUpdate(); + + expect(mockedUseTTS).toHaveBeenCalled(); + }); +}); diff --git a/src/components/FxTTS.tsx b/src/components/FxTTS.tsx new file mode 100644 index 0000000000..e617927e8b --- /dev/null +++ b/src/components/FxTTS.tsx @@ -0,0 +1,21 @@ +import { useAtomValue } from 'jotai'; +import type React from 'react'; +import { useTTS } from '~/hooks/useTTS'; +import { useTTSFeatureEnabled } from '~/hooks/useTTSFeatureEnabled'; +import speechState from '~/store/atoms/speech'; + +const FxTTSInner: React.FC = () => { + useTTS(); + return null; +}; + +// TTS無効時は useTTSText のテキスト構築(毎tick約19ms)ごとスキップする。 +// 有効化時にマウントされ直し、行先選択直後と同じ初回発話抑止から始まる。 +// Remote Config のキルスイッチ(tts_enabled=false)時は、ユーザー設定が有効でも +// 再生を止める。設定画面のトグル表示(無効化)と実挙動を一致させるため。 +// 起動時の非同期取得完了後に false が届いたケースでも購読経由で確実にアンマウントする。 +export const FxTTS: React.FC = () => { + const { enabled } = useAtomValue(speechState); + const ttsFeatureEnabled = useTTSFeatureEnabled(); + return enabled && ttsFeatureEnabled ? : null; +}; diff --git a/src/components/GlobalToast.tsx b/src/components/GlobalToast.tsx index c3f6b869c9..7752b20e77 100644 --- a/src/components/GlobalToast.tsx +++ b/src/components/GlobalToast.tsx @@ -1,6 +1,5 @@ import { useAtomValue } from 'jotai'; import type React from 'react'; -import { useMemo } from 'react'; import type { DimensionValue } from 'react-native'; import type { ToastConfigParams } from 'react-native-toast-message'; import Toast, { BaseToast, ErrorToast } from 'react-native-toast-message'; @@ -9,65 +8,92 @@ import { isLEDThemeAtom } from '~/store/atoms/theme'; import isTablet from '~/utils/isTablet'; import { RFValue } from '~/utils/rfValue'; -export const GlobalToast: React.FC = () => { +const contentContainerStyle = { + paddingHorizontal: 24, + paddingVertical: 12, +}; + +const useToastStyles = (ledColor: string, defaultColor: string) => { const isLEDTheme = useAtomValue(isLEDThemeAtom); - const toastConfig = useMemo(() => { - const getToastStyle = (ledColor: string, defaultColor: string) => ({ + return { + style: { borderLeftColor: isLEDTheme ? ledColor : defaultColor, borderLeftWidth: 16, backgroundColor: isLEDTheme ? LED_THEME_BG_COLOR : '#333', borderRadius: isLEDTheme ? 0 : 6, width: (isTablet ? '50%' : '90%') as DimensionValue, - }); - - const contentContainerStyle = { - paddingHorizontal: 24, - paddingVertical: 12, - }; - - const text1Style = { + }, + text1Style: { color: '#fff', fontFamily: isLEDTheme ? FONTS.JFDotJiskan24h : undefined, fontSize: RFValue(14), - }; - - const text2Style = { + }, + text2Style: { color: '#ccc', fontFamily: isLEDTheme ? FONTS.JFDotJiskan24h : undefined, fontSize: RFValue(11), - }; + }, + }; +}; + +type ThemedToastProps = ToastConfigParams & { + ledColor: string; + defaultColor: string; +}; + +const ThemedBaseToast: React.FC = ({ + ledColor, + defaultColor, + ...props +}) => { + const { style, text1Style, text2Style } = useToastStyles( + ledColor, + defaultColor + ); + + return ( + + ); +}; - return { - success: (props: ToastConfigParams) => ( - - ), - error: (props: ToastConfigParams) => ( - - ), - info: (props: ToastConfigParams) => ( - - ), - }; - }, [isLEDTheme]); +const ThemedErrorToast: React.FC = ({ + ledColor, + defaultColor, + ...props +}) => { + const { style, text1Style, text2Style } = useToastStyles( + ledColor, + defaultColor + ); - return ; + return ( + + ); }; + +const toastConfig = { + success: (props: ToastConfigParams) => ( + + ), + error: (props: ToastConfigParams) => ( + + ), + info: (props: ToastConfigParams) => ( + + ), +}; + +export const GlobalToast: React.FC = () => ; diff --git a/src/components/LineBoard.tsx b/src/components/LineBoard.tsx index 74ed16c1a6..fca18508f9 100644 --- a/src/components/LineBoard.tsx +++ b/src/components/LineBoard.tsx @@ -8,7 +8,6 @@ import { leftStationsAtom } from '../store/atoms/navigation'; import { themeAtom } from '../store/atoms/theme'; import isTablet from '../utils/isTablet'; import { isBusLine } from '../utils/line'; -import { RFValue } from '../utils/rfValue'; import LineBoardE231 from './LineBoardE231'; import LineBoardEast from './LineBoardEast'; import LineBoardJO from './LineBoardJO'; @@ -25,12 +24,6 @@ export interface Props { const styles = StyleSheet.create({ flexOne: { flex: 1 }, - bottomNotice: { - position: 'absolute', - bottom: isTablet ? 96 : 12, - fontWeight: 'bold', - fontSize: RFValue(12), - }, }); const LineBoard: React.FC = ({ hasTerminus = false }: Props) => { diff --git a/src/components/LineBoard/shared/components/EstimatedMinutesUnitLabel.test.tsx b/src/components/LineBoard/shared/components/EstimatedMinutesUnitLabel.test.tsx new file mode 100644 index 0000000000..fbafea3650 --- /dev/null +++ b/src/components/LineBoard/shared/components/EstimatedMinutesUnitLabel.test.tsx @@ -0,0 +1,54 @@ +import { render } from '@testing-library/react-native'; +import { createStore, Provider } from 'jotai'; +import type { HeaderTransitionState } from '~/models/HeaderTransitionState'; +import { headerStateAtom } from '~/store/atoms/navigation'; +import { EstimatedMinutesUnitLabel } from './EstimatedMinutesUnitLabel'; + +jest.mock('~/utils/isTablet', () => ({ + __esModule: true, + default: false, +})); + +const renderWithState = (headerState: HeaderTransitionState) => { + const store = createStore(); + store.set(headerStateAtom, headerState); + return render( + + + + ); +}; + +describe('EstimatedMinutesUnitLabel', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it.each(['CURRENT', 'NEXT', 'ARRIVING'])( + '日本語State(%s)では「分」を表示する', + (state) => { + const { getByText } = renderWithState(state); + expect(getByText('分')).toBeTruthy(); + } + ); + + it('かなStateでは「分」を表示する', () => { + const { getByText } = renderWithState('CURRENT_KANA'); + expect(getByText('分')).toBeTruthy(); + }); + + it('英語Stateでは「min.」を表示する', () => { + const { getByText } = renderWithState('CURRENT_EN'); + expect(getByText('min.')).toBeTruthy(); + }); + + it('中国語Stateでは「分」を表示する', () => { + const { getByText } = renderWithState('CURRENT_ZH'); + expect(getByText('分')).toBeTruthy(); + }); + + it('韓国語Stateでは「분」を表示する', () => { + const { getByText } = renderWithState('CURRENT_KO'); + expect(getByText('분')).toBeTruthy(); + }); +}); diff --git a/src/components/LineBoard/shared/components/EstimatedMinutesUnitLabel.tsx b/src/components/LineBoard/shared/components/EstimatedMinutesUnitLabel.tsx new file mode 100644 index 0000000000..599115dc35 --- /dev/null +++ b/src/components/LineBoard/shared/components/EstimatedMinutesUnitLabel.tsx @@ -0,0 +1,52 @@ +import { atom, useAtomValue } from 'jotai'; +import type React from 'react'; +import { StyleSheet, type TextStyle } from 'react-native'; +import { headerStateAtom } from '~/store/atoms/navigation'; +import isTablet from '~/utils/isTablet'; +import Typography from '../../../Typography'; + +const styles = StyleSheet.create({ + text: { + color: '#fff', + fontWeight: 'bold', + fontSize: isTablet ? 21 : 15, + // 路線色バーの上に重ねて描画されるため、どの路線色でも判読できるよう縁取る + textShadowColor: '#000', + textShadowOffset: { width: 0, height: 0 }, + textShadowRadius: 2, + }, +}); + +// ヘッダーの言語Stateごとの単位表記。headerStateAtomは数秒ごとに +// ローテーションするため、算出済み文字列のderived atomを購読することで +// 単位が実際に変わったときだけ再レンダーされるようにする。 +const estimatedMinutesUnitAtom = atom((get) => { + const langState = get(headerStateAtom).split('_')[1] ?? 'JA'; + switch (langState) { + case 'EN': + return 'min.'; + case 'KO': + return '분'; + // JA・KANA・ZH (中国語も単位は「分」で通じるため共通) + default: + return '分'; + } +}); + +export type EstimatedMinutesUnitLabelProps = { + style?: TextStyle; +}; + +// ETAの残り分数はドット内に数字のみで表示されるため、最後のドットの右隣に +// 単位を添えて数字の意味を示す。 +export const EstimatedMinutesUnitLabel: React.FC< + EstimatedMinutesUnitLabelProps +> = ({ style }) => { + const unit = useAtomValue(estimatedMinutesUnitAtom); + + return ( + + {unit} + + ); +}; diff --git a/src/components/LineBoard/shared/components/LineDot.test.tsx b/src/components/LineBoard/shared/components/LineDot.test.tsx index 597305d2f3..7cbc6d3453 100644 --- a/src/components/LineBoard/shared/components/LineDot.test.tsx +++ b/src/components/LineBoard/shared/components/LineDot.test.tsx @@ -1,6 +1,8 @@ import { render } from '@testing-library/react-native'; +import { createStore, Provider } from 'jotai'; import type { Line, Station } from '~/@types/graphql'; -import { LineDot } from './LineDot'; +import { headerStateAtom } from '~/store/atoms/navigation'; +import { LineDot, type LineDotProps } from './LineDot'; // モック設定 jest.mock('~/hooks/useScale', () => ({ @@ -172,4 +174,57 @@ describe('LineDot', () => { // PadLineMarksがレンダリングされていることを確認 expect(getByTestId('pad-line-marks')).toBeTruthy(); }); + + describe('ETA単位ラベル', () => { + const renderWithHeaderState = ( + props: Partial, + headerState: 'CURRENT' | 'CURRENT_EN' = 'CURRENT' + ) => { + const store = createStore(); + store.set(headerStateAtom, headerState); + return render( + + + + ); + }; + + beforeEach(() => { + const getIsPass = require('~/utils/isPass').default; + (getIsPass as jest.Mock).mockReturnValue(false); + }); + + it('isLastかつestimatedMinutesありの場合、「分」を表示する', () => { + const { getByText } = renderWithHeaderState({ + isLast: true, + estimatedMinutes: 5, + }); + expect(getByText('分')).toBeTruthy(); + }); + + it('英語Stateでは「min.」を表示する', () => { + const { getByText } = renderWithHeaderState( + { isLast: true, estimatedMinutes: 5 }, + 'CURRENT_EN' + ); + expect(getByText('min.')).toBeTruthy(); + }); + + it('isLastでもestimatedMinutesがない場合は表示しない', () => { + const { queryByText } = renderWithHeaderState({ isLast: true }); + expect(queryByText('分')).toBeNull(); + }); + + it('estimatedMinutesがあってもisLastでない場合は表示しない', () => { + const { queryByText } = renderWithHeaderState({ estimatedMinutes: 5 }); + expect(queryByText('分')).toBeNull(); + }); + }); }); diff --git a/src/components/LineBoard/shared/components/LineDot.tsx b/src/components/LineBoard/shared/components/LineDot.tsx index 9cd3aac48e..d5dabc15e3 100644 --- a/src/components/LineBoard/shared/components/LineDot.tsx +++ b/src/components/LineBoard/shared/components/LineDot.tsx @@ -9,6 +9,7 @@ import PadLineMarks from '../../../PadLineMarks'; import PassChevronEast from '../../../PassChevronEast'; import { commonLineBoardStyles as styles } from '../styles/commonStyles'; import { EstimatedMinutesBadge } from './EstimatedMinutesBadge'; +import { EstimatedMinutesUnitLabel } from './EstimatedMinutesUnitLabel'; const localStyles = StyleSheet.create({ estimatedMinutesOverlay: { @@ -18,6 +19,14 @@ const localStyles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', }, + // 見えているドット(chevronGradient)の右にドットと同じ高さで縦中央揃え + estimatedMinutesUnitContainer: { + position: 'absolute', + top: 0, + left: isTablet ? 48 + 24 : 32 + 16, + height: isTablet ? 36 : 24, + justifyContent: 'center', + }, }); export type LineDotProps = { @@ -28,6 +37,8 @@ export type LineDotProps = { passed: boolean; isOdakyu?: boolean; estimatedMinutes?: number | null; + // 最後尾セルのドットのときtrue。ETA表示中は右隣に単位(分/min.)を添える + isLast?: boolean; }; export const LineDot: React.FC = ({ @@ -38,6 +49,7 @@ export const LineDot: React.FC = ({ passed, isOdakyu = false, estimatedMinutes, + isLast = false, }) => { const { widthScale } = useScale(); @@ -108,6 +120,14 @@ export const LineDot: React.FC = ({ ) : null} + {isLast && estimatedMinutes != null ? ( + + + + ) : null} { EstimatedMinutesBadge: jest.fn(({ estimatedMinutes }) => ( {estimatedMinutes} )), + EstimatedMinutesUnitLabel: jest.fn(() => ), StationName: jest.fn(() => null), }; }); @@ -166,4 +167,48 @@ describe('LineBoardE231', () => { ); expect(EstimatedMinutesBadge).not.toHaveBeenCalled(); }); + + it('最後の駅にETAがある場合、単位ラベルが表示される', () => { + const { useEstimatedMinutesByStationId } = require('~/hooks'); + useEstimatedMinutesByStationId.mockReturnValueOnce(new Map([[2, 5]])); + const { + EstimatedMinutesUnitLabel, + } = require('./LineBoard/shared/components'); + render( + + ); + expect(EstimatedMinutesUnitLabel).toHaveBeenCalled(); + }); + + it('最後の駅以外のETAには単位ラベルが表示されない', () => { + const { useEstimatedMinutesByStationId } = require('~/hooks'); + useEstimatedMinutesByStationId.mockReturnValueOnce(new Map([[2, 5]])); + const { + EstimatedMinutesBadge, + EstimatedMinutesUnitLabel, + } = require('./LineBoard/shared/components'); + const threeStations = [ + ...mockStations, + { + id: 3, + groupId: 3, + name: '横浜', + line: mockLine, + } as unknown as Station, + ]; + render( + + ); + // 中間駅(id=2)のETAバッジは表示されるが、単位ラベルは最後尾専用 + expect(EstimatedMinutesBadge).toHaveBeenCalled(); + expect(EstimatedMinutesUnitLabel).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/LineBoardE231.tsx b/src/components/LineBoardE231.tsx index 06e852faa2..28994bb34c 100644 --- a/src/components/LineBoardE231.tsx +++ b/src/components/LineBoardE231.tsx @@ -1,6 +1,6 @@ import { useAtomValue } from 'jotai'; import React, { useCallback, useMemo } from 'react'; -import { Platform, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import type { Line, Station } from '~/@types/graphql'; import { useCurrentLine, @@ -13,13 +13,13 @@ import { import { useScale } from '~/hooks/useScale'; import { arrivedAtom } from '~/store/atoms/station'; import { isEnAtom } from '~/store/selectors/isEn'; -import { RFValue } from '~/utils/rfValue'; import { selectedLineAtom } from '../store/atoms/line'; import getIsPass from '../utils/isPass'; import isTablet from '../utils/isTablet'; import { ChevronE231 } from './ChevronE231'; import { EstimatedMinutesBadge, + EstimatedMinutesUnitLabel, StationName, } from './LineBoard/shared/components'; import { @@ -69,18 +69,6 @@ const localStyles = StyleSheet.create({ borderTopColor: 'transparent', borderBottomColor: 'transparent', }, - stationNameMapContainer: { - flex: 1, - justifyContent: 'flex-end', - marginBottom: 8, - }, - stationName: { - fontSize: RFValue(18), - fontWeight: 'bold', - color: '#3a3a3a', - marginLeft: 5, - marginBottom: Platform.select({ android: -6, ios: 0 }), - }, chevron: { position: 'absolute', zIndex: 9999, @@ -114,6 +102,14 @@ const localStyles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', }, + // ドット矩形(dotInner)の右にドットと同じ高さで縦中央揃え + estimatedMinutesUnitContainer: { + position: 'absolute', + top: 0, + left: isTablet ? 44 + 24 : 36 + 16, + height: isTablet ? 36 : 24, + justifyContent: 'center', + }, marksContainer: { top: 38, position: 'absolute', @@ -273,6 +269,16 @@ const StationNameCell: React.FC = ({ /> ) : null} + {stations.length - 1 === index && + estimatedMinutes != null && + !(passed && !arrived) ? ( + + + + ) : null} )} diff --git a/src/components/LineBoardEast.test.tsx b/src/components/LineBoardEast.test.tsx index c22ac0e46c..ac7d327add 100644 --- a/src/components/LineBoardEast.test.tsx +++ b/src/components/LineBoardEast.test.tsx @@ -204,6 +204,25 @@ describe('LineBoardEast', () => { ); }); + it('最後の駅のLineDotにのみisLast=trueが渡される', () => { + const { LineDot } = require('./LineBoard/shared/components'); + render( + + ); + expect(LineDot).toHaveBeenCalledWith( + expect.objectContaining({ station: mockStations[0], isLast: false }), + undefined + ); + expect(LineDot).toHaveBeenCalledWith( + expect.objectContaining({ station: mockStations[1], isLast: true }), + undefined + ); + }); + it('isOdakyu時はETAクエリをskipして呼び出す', () => { const { useEstimateArrivalTimes } = require('~/hooks'); render( diff --git a/src/components/LineBoardEast.tsx b/src/components/LineBoardEast.tsx index 8aa266b4ec..65df50f45d 100644 --- a/src/components/LineBoardEast.tsx +++ b/src/components/LineBoardEast.tsx @@ -412,6 +412,7 @@ const StationNameCellBase: React.FC = ({ passed={passed} isOdakyu={isOdakyu} estimatedMinutes={estimatedMinutes} + isLast={stations.length - 1 === index} /> {stations.length - 1 === index ? ( isOdakyu ? ( diff --git a/src/components/LineBoardJO.test.tsx b/src/components/LineBoardJO.test.tsx index 7a2d80bcff..f26dbd04ea 100644 --- a/src/components/LineBoardJO.test.tsx +++ b/src/components/LineBoardJO.test.tsx @@ -82,6 +82,7 @@ jest.mock('./LineBoard/shared/components', () => { EstimatedMinutesBadge: jest.fn(({ estimatedMinutes }) => ( {estimatedMinutes} )), + EstimatedMinutesUnitLabel: jest.fn(() => ), LineDot: jest.fn(() => null), StationName: jest.fn(() => null), }; @@ -251,6 +252,48 @@ describe('LineBoardJO', () => { ); }); + it('最後の駅にETAがある場合、単位ラベルが表示される', () => { + const { useEstimatedMinutesByStationId } = require('~/hooks'); + useEstimatedMinutesByStationId.mockReturnValueOnce(new Map([[2, 5]])); + const { + EstimatedMinutesUnitLabel, + } = require('./LineBoard/shared/components'); + render( + + ); + expect(EstimatedMinutesUnitLabel).toHaveBeenCalled(); + }); + + it('最後の駅以外のETAには単位ラベルが表示されない', () => { + const { useEstimatedMinutesByStationId } = require('~/hooks'); + useEstimatedMinutesByStationId.mockReturnValueOnce(new Map([[2, 5]])); + const { + EstimatedMinutesBadge, + EstimatedMinutesUnitLabel, + } = require('./LineBoard/shared/components'); + const threeStations = [ + ...mockStations, + { + id: 3, + groupId: 3, + name: '横浜', + line: mockLine, + } as unknown as Station, + ]; + render( + + ); + // 中間駅(id=2)のETAバッジは表示されるが、単位ラベルは最後尾専用 + expect(EstimatedMinutesBadge).toHaveBeenCalled(); + expect(EstimatedMinutesUnitLabel).not.toHaveBeenCalled(); + }); + it('通過駅の場合、PassChevronEastが表示される', () => { const getIsPass = require('~/utils/isPass').default; getIsPass.mockReturnValue(true); diff --git a/src/components/LineBoardJO.tsx b/src/components/LineBoardJO.tsx index ea0d0c0a01..42b2abc4a9 100644 --- a/src/components/LineBoardJO.tsx +++ b/src/components/LineBoardJO.tsx @@ -21,7 +21,10 @@ import isTablet from '../utils/isTablet'; import { getNumberingColor } from '../utils/numbering'; import { ChevronJO } from './ChevronJO'; import { JOCurrentArrowEdge } from './JOCurrentArrowEdge'; -import { EstimatedMinutesBadge } from './LineBoard/shared/components'; +import { + EstimatedMinutesBadge, + EstimatedMinutesUnitLabel, +} from './LineBoard/shared/components'; import { useIncludesLongStationName } from './LineBoard/shared/hooks/useBarStyles'; import { BAR_BOTTOM_JO, @@ -50,6 +53,14 @@ const localStyles = StyleSheet.create({ bottom: isTablet ? '40%' : undefined, marginLeft: isTablet ? 48 : 32, }, + // barDot(未通過時は32px)の右にドットと同じ高さで縦中央揃え + estimatedMinutesUnitContainer: { + position: 'absolute', + top: 0, + left: 32 + 24, + height: 32, + justifyContent: 'center', + }, }); const styles = { ...commonLineBoardStyles, ...localStyles }; @@ -396,6 +407,14 @@ const LineBoardJO: React.FC = ({ stations, lineColors }: Props) => { {estimatedMinutes != null ? ( ) : null} + {i === stations.length - 1 && estimatedMinutes != null ? ( + + + + ) : null} )} diff --git a/src/components/LineBoardJRKyushu.test.tsx b/src/components/LineBoardJRKyushu.test.tsx index ce0290d3fe..c113fc0ef5 100644 --- a/src/components/LineBoardJRKyushu.test.tsx +++ b/src/components/LineBoardJRKyushu.test.tsx @@ -179,6 +179,25 @@ describe('LineBoardJRKyushu', () => { ); }); + it('最後の駅のLineDotにのみisLast=trueが渡される', () => { + const { LineDot } = require('./LineBoard/shared/components'); + render( + + ); + expect(LineDot).toHaveBeenCalledWith( + expect.objectContaining({ station: mockStations[0], isLast: false }), + undefined + ); + expect(LineDot).toHaveBeenCalledWith( + expect.objectContaining({ station: mockStations[1], isLast: true }), + undefined + ); + }); + it('NumberingIconコンポーネントが駅番号付きの駅に対してレンダリングされる', () => { const NumberingIcon = require('./NumberingIcon').default; render( diff --git a/src/components/LineBoardJRKyushu.tsx b/src/components/LineBoardJRKyushu.tsx index 1856f647d2..b156c0cb8b 100644 --- a/src/components/LineBoardJRKyushu.tsx +++ b/src/components/LineBoardJRKyushu.tsx @@ -357,6 +357,7 @@ const StationNameCellBase: React.FC = ({ arrived={arrived} passed={passed} estimatedMinutes={estimatedMinutes} + isLast={stations.length - 1 === index} /> {stations.length - 1 === index ? ( diff --git a/src/components/LineBoardSaikyo.tsx b/src/components/LineBoardSaikyo.tsx index bd0d07507a..1a8518e923 100644 --- a/src/components/LineBoardSaikyo.tsx +++ b/src/components/LineBoardSaikyo.tsx @@ -1,7 +1,7 @@ import { LinearGradient } from 'expo-linear-gradient'; import { useAtomValue } from 'jotai'; import React, { useCallback, useMemo } from 'react'; -import { Platform, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import type { Line, Station } from '~/@types/graphql'; import { useCurrentLine, @@ -14,7 +14,6 @@ import { import { useScale } from '~/hooks/useScale'; import { arrivedAtom } from '~/store/atoms/station'; import { isEnAtom } from '~/store/selectors/isEn'; -import { RFValue } from '~/utils/rfValue'; import { selectedLineAtom } from '../store/atoms/line'; import getIsPass from '../utils/isPass'; import isTablet from '../utils/isTablet'; @@ -42,24 +41,6 @@ interface Props { // Local style overrides specific to Saikyo const localStyles = StyleSheet.create({ - stationNameMapContainer: { - flex: 1, - justifyContent: 'flex-end', - marginBottom: 8, - }, - stationName: { - fontSize: RFValue(18), - fontWeight: 'bold', - color: '#3a3a3a', - marginLeft: 5, - marginBottom: Platform.select({ android: -6, ios: 0 }), - }, - stationNameHorizontal: { - fontSize: RFValue(18), - fontWeight: 'bold', - transform: [{ rotate: '-55deg' }], - color: '#3a3a3a', - }, chevron: { position: 'absolute', zIndex: 9999, @@ -273,6 +254,7 @@ const StationNameCellBase: React.FC = ({ arrived={arrived} passed={passed} estimatedMinutes={estimatedMinutes} + isLast={stations.length - 1 === index} /> {stations.length - 1 === index && ( = ({ arrived={arrived} passed={passed} estimatedMinutes={estimatedMinutes} + isLast={isLastStation} /> {isLastStation ? ( = ({ toValue: 1, duration: YAMANOTE_CHEVRON_MOVE_DURATION * 2, easing: Easing.linear, - useNativeDriver: false, + useNativeDriver: true, }), Animated.timing(chevronTimeline, { toValue: 0, duration: 0, - useNativeDriver: false, + useNativeDriver: true, }), ]) ).start(); diff --git a/src/components/Permitted.tsx b/src/components/Permitted.tsx index ad316d742e..95b2c8c3ba 100644 --- a/src/components/Permitted.tsx +++ b/src/components/Permitted.tsx @@ -43,16 +43,17 @@ import { import { useTrainTypeModal } from '../hooks/useTrainTypeModal'; import { storage } from '../lib/storage'; import { THEME_PREFERENCE, type ThemePreference } from '../models/Theme'; -import { - etaAssistManualEnabledAtom, - portraitModeEnabledAtom, -} from '../store/atoms/experimental'; +import { portraitModeEnabledAtom } from '../store/atoms/experimental'; import navigationState, { autoModeEnabledAtom, isAppLatestAtom, } from '../store/atoms/navigation'; import notifyState from '../store/atoms/notify'; -import { pictureInPictureAtom } from '../store/atoms/pictureInPicture'; +import { + pictureInPictureActiveAtom, + pictureInPictureAtom, + pictureInPictureEnabledAtom, +} from '../store/atoms/pictureInPicture'; import speechState from '../store/atoms/speech'; import { selectedBoundAtom } from '../store/atoms/station'; import { themePreferenceAtom } from '../store/atoms/theme'; @@ -67,6 +68,45 @@ type Props = { children: React.ReactNode; }; +// PermittedLayout 本体から切り離したレンダーレスの副作用ホスト。 +// useWrongDirectionDetectorEffect は locationAtom を、useAppleWatch / +// useAndroidWearable は駅状態を購読するため、本体に置くと位置更新のたびに +// レイアウト全体が再レンダーされてしまう。 +const FxAppleWatchInner: React.FC = () => { + useAppleWatch(); + return null; +}; +const FxAndroidWearableInner: React.FC = () => { + useAndroidWearable(); + return null; +}; +const FxCheckStoreVersion: React.FC = () => { + useCheckStoreVersion(); + return null; +}; +const FxWrongDirectionDetector: React.FC = () => { + // 逆方向検知ロジックの計算を 1 箇所だけで実行し、結果は atom 経由で他のフックに配る。 + // useRefreshStation / useWarningInfo から個別に呼ぶと位置更新ごとに getDistance と + // state 更新が二重に走ってバッテリーを余計に消費するため、ここに集約している。 + useWrongDirectionDetectorEffect(); + return null; +}; + +const PermittedLayoutEffects: React.FC = () => { + // 高頻度購読のフックをホスト自身で呼ぶと、その再レンダーが sibling の + // ウェアラブル子コンポーネントへも伝播するため、1 フック = 1 コンポーネントで分離する。 + // ウェアラブル連携は各プラットフォーム専用。対象外の OS では + // メッセージ組み立て(毎tickの派生計算)ごとマウントしない。 + return ( + <> + + + {Platform.OS === 'ios' && } + {Platform.OS === 'android' && } + + ); +}; + const PermittedLayout: React.FC = ({ children }: Props) => { const selectedBound = useAtomValue(selectedBoundAtom); const { untouchableModeEnabled, devOverlayEnabled } = @@ -78,9 +118,8 @@ const PermittedLayout: React.FC = ({ children }: Props) => { const setNotify = useSetAtom(notifyState); const setPictureInPicture = useSetAtom(pictureInPictureAtom); const setPortraitModeEnabled = useSetAtom(portraitModeEnabledAtom); - const setEtaAssistManualEnabled = useSetAtom(etaAssistManualEnabledAtom); - const { enabled: pictureInPictureEnabled, active: pictureInPictureActive } = - useAtomValue(pictureInPictureAtom); + const pictureInPictureEnabled = useAtomValue(pictureInPictureEnabledAtom); + const pictureInPictureActive = useAtomValue(pictureInPictureActiveAtom); const isAppActive = useIsAppActive(); const setTuning = useSetAtom(tuningState); const [themePreference, setThemePreference] = useAtom(themePreferenceAtom); @@ -90,14 +129,6 @@ const PermittedLayout: React.FC = ({ children }: Props) => { const [isThemeListModalVisible, setIsThemeListModalVisible] = useState(false); const pendingThemeListModalRef = useRef(false); - useCheckStoreVersion(); - useAppleWatch(); - useAndroidWearable(); - // 逆方向検知ロジックの計算を 1 箇所だけで実行し、結果は atom 経由で他のフックに配る。 - // useRefreshStation / useWarningInfo から個別に呼ぶと位置更新ごとに getDistance と - // state 更新が二重に走ってバッテリーを余計に消費するため、ここに集約している。 - useWrongDirectionDetectorEffect(); - const user = useCachedInitAnonymousUser(); const currentLine = useCurrentLine(); const navigation = useNavigation(); @@ -440,12 +471,9 @@ const PermittedLayout: React.FC = ({ children }: Props) => { const portraitModeEnabledStr = storage.getString( STORAGE_KEYS.PORTRAIT_MODE_ENABLED ); - const etaAssistManualEnabledStr = storage.getString( - STORAGE_KEYS.ETA_ASSIST_MANUAL_ENABLED - ); // NOTE: powerSavingLocationEnabledAtom はここでは復元しない。effect復元だと // 継続測位がデフォルト精度で一度起動してから再起動されるため、 - // atom定義側(store/atoms/experimental.ts)でMMKVから同期的に初期値を確定している。 + // atom定義側(store/atoms/battery.ts)でMMKVから同期的に初期値を確定している。 if (themePreferenceKey) { setThemePreference(themePreferenceKey as ThemePreference); @@ -555,9 +583,6 @@ const PermittedLayout: React.FC = ({ children }: Props) => { if (portraitModeEnabledStr) { setPortraitModeEnabled(portraitModeEnabledStr === 'true'); } - if (etaAssistManualEnabledStr) { - setEtaAssistManualEnabled(etaAssistManualEnabledStr === 'true'); - } }; loadSettings(); @@ -569,7 +594,6 @@ const PermittedLayout: React.FC = ({ children }: Props) => { setNotify, setPictureInPicture, setPortraitModeEnabled, - setEtaAssistManualEnabled, ]); useEffect(() => { @@ -668,6 +692,7 @@ const PermittedLayout: React.FC = ({ children }: Props) => { return ( + = ({ trainType }: Props) => { diff --git a/src/components/TransferLineMark.tsx b/src/components/TransferLineMark.tsx index 2b3c8bf6b3..ef907ee375 100644 --- a/src/components/TransferLineMark.tsx +++ b/src/components/TransferLineMark.tsx @@ -36,10 +36,6 @@ const styles = StyleSheet.create({ alignItems: 'center', overflow: 'visible', }, - signPathWrapper: { - flexDirection: 'row', - flexWrap: 'wrap', - }, outline: { position: 'absolute', top: 0, diff --git a/src/components/TypeChangeNotify.tsx b/src/components/TypeChangeNotify.tsx index 5c7c171115..acf05e736f 100644 --- a/src/components/TypeChangeNotify.tsx +++ b/src/components/TypeChangeNotify.tsx @@ -87,10 +87,6 @@ const styles = StyleSheet.create({ bottom: -barHeight, position: 'absolute', }, - joBar: { - position: 'absolute', - height: 32, - }, centerCircle: { position: 'absolute', width: isTablet ? 50 : 24, diff --git a/src/constants/location.ts b/src/constants/location.ts index cd87c4a977..9917456ec6 100644 --- a/src/constants/location.ts +++ b/src/constants/location.ts @@ -1,15 +1,17 @@ import * as Location from 'expo-location'; export const LOCATION_TASK_NAME = 'trainlcd-background-location-task'; -export const LOCATION_ACCURACY = Location.Accuracy.Highest; -// 省電力測位モードでも駅判定の信頼性を維持するためHighを使用する。iOSでは -// kCLLocationAccuracyBestからNearestTenMetersへ一段下がる。AndroidではHighestと -// 同じ高精度プロバイダを維持し、更新間隔の緩和によって電池消費を抑える。 -export const LOCATION_ACCURACY_POWER_SAVING = Location.Accuracy.High; -export const LOCATION_DISTANCE_INTERVAL = 10; -export const LOCATION_TIME_INTERVAL = 5000; -export const LOCATION_DISTANCE_INTERVAL_POWER_SAVING = 25; -export const LOCATION_TIME_INTERVAL_POWER_SAVING = 10000; +// 旧・省電力測位モードの精度と更新間隔を実車検証を経て既定値へ昇格した。 +// 駅判定の信頼性を維持するためHighを使用する。iOSではkCLLocationAccuracyBestから +// NearestTenMetersへ一段下がる。AndroidではHighestと同じ高精度プロバイダを維持し、 +// 更新間隔の緩和によって電池消費と発熱を抑える。 +export const LOCATION_ACCURACY = Location.Accuracy.High; +// 省電力測位モード(実験的機能)では電池優先のBalancedまで精度を下げる。iOSでは +// kCLLocationAccuracyHundredMeters、AndroidではGPSを常用しない省電力プロバイダに +// なるため、駅判定の精度低下と引き換えに電池消費をさらに抑える。 +export const LOCATION_ACCURACY_POWER_SAVING = Location.Accuracy.Balanced; +export const LOCATION_DISTANCE_INTERVAL = 25; +export const LOCATION_TIME_INTERVAL = 10000; // 最大許容精度(m)のフォールバック既定値。実効値は Worker の /config/remote が返す // max_permit_accuracy を参照する(src/lib/remoteConfig.ts の getMaxPermitAccuracy)。 @@ -26,9 +28,8 @@ export const LOCATION_WATCH_OPTIONS: Location.LocationOptions = { } as const; export const LOCATION_WATCH_OPTIONS_POWER_SAVING: Location.LocationOptions = { + ...LOCATION_WATCH_OPTIONS, accuracy: LOCATION_ACCURACY_POWER_SAVING, - distanceInterval: LOCATION_DISTANCE_INTERVAL_POWER_SAVING, - timeInterval: LOCATION_TIME_INTERVAL_POWER_SAVING, } as const; export const LOCATION_TASK_OPTIONS: Location.LocationTaskOptions = { @@ -37,18 +38,19 @@ export const LOCATION_TASK_OPTIONS: Location.LocationTaskOptions = { // deferredUpdatesを両方0にするとFLPの更新ごとにジョブがスケジュールされ、 // Android 16でクォータ超過によりバックグラウンド更新が停止する。 // distanceは0にしないと停車中に更新が届かなくなる(AND条件のため) + // バッチ間隔は更新間隔に合わせ、バックグラウンドでのJS起床回数を抑える。 deferredUpdatesInterval: LOCATION_TIME_INTERVAL, deferredUpdatesDistance: 0, pausesUpdatesAutomatically: false, } as const; +// 省電力測位モード(実験的機能)。更新間隔は既定値と共通のまま、精度をBalancedへ +// 下げ、停車中の測位自動休止(iOSのみ)を追加で許可する。 export const LOCATION_TASK_OPTIONS_POWER_SAVING: Location.LocationTaskOptions = { - ...LOCATION_WATCH_OPTIONS_POWER_SAVING, + ...LOCATION_TASK_OPTIONS, + accuracy: LOCATION_ACCURACY_POWER_SAVING, // 停車中はiOSに測位ハードウェアの休止を許可し、移動再開時にOtherNavigationの // 活動種別を手掛かりとして自動再開させる。 pausesUpdatesAutomatically: true, - // バッチ間隔も更新間隔に合わせ、バックグラウンドでのJS起床回数を半減する。 - deferredUpdatesInterval: LOCATION_TIME_INTERVAL_POWER_SAVING, - deferredUpdatesDistance: 0, } as const; diff --git a/src/constants/storage.ts b/src/constants/storage.ts index 2cd51385e4..f0f9c21a3d 100644 --- a/src/constants/storage.ts +++ b/src/constants/storage.ts @@ -36,7 +36,6 @@ export const STORAGE_KEYS = { WRONG_DIRECTION_NOTIFY_ENABLED: '@TrainLCD:wrongDirectionNotifyEnabled', PICTURE_IN_PICTURE_ENABLED: '@TrainLCD:pictureInPictureEnabled', PORTRAIT_MODE_ENABLED: '@TrainLCD:portraitModeEnabled', - ETA_ASSIST_MANUAL_ENABLED: '@TrainLCD:etaAssistManualEnabled', POWER_SAVING_LOCATION_ENABLED: '@TrainLCD:powerSavingLocationEnabled', } as const; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 58d3e98133..e66792c946 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -86,6 +86,7 @@ export { useTransferLinesFromStation } from './useTransferLinesFromStation'; export { useTransitionHeaderState } from './useTransitionHeaderState'; export { useTTS } from './useTTS'; export { useTTSCache } from './useTTSCache'; +export { useTTSFeatureEnabled } from './useTTSFeatureEnabled'; export { useTTSText } from './useTTSText'; export { useTypeWillChange } from './useTypeWillChange'; export { useUnderMaintenance } from './useUnderMaintenance'; diff --git a/src/hooks/useLineSelection.ts b/src/hooks/useLineSelection.ts index 5c62dc1b68..ceb9ba59c4 100644 --- a/src/hooks/useLineSelection.ts +++ b/src/hooks/useLineSelection.ts @@ -119,10 +119,20 @@ export const useLineSelection = (): UseLineSelectionResult => { pendingTrainType: null, })); - const result = await fetchStationsByLineId({ - variables: { lineId, stationId: lineStationId }, - }); - const fetchedStations = result.data?.lineStations ?? []; + // 駅一覧と種別一覧は互いに独立したクエリなので並列で取得する + const [{ data }, fetchedTrainTypesData] = await Promise.all([ + fetchStationsByLineId({ + variables: { lineId, stationId: lineStationId }, + }), + line.station?.hasTrainTypes + ? fetchTrainTypes({ + variables: { + stationId: lineStationId, + }, + }) + : null, + ]); + const fetchedStations = data?.lineStations ?? []; const pendingStation = fetchedStations.find((s) => s.id === lineStationId) ?? null; @@ -133,13 +143,9 @@ export const useLineSelection = (): UseLineSelectionResult => { pendingStations: fetchedStations, })); - if (line.station?.hasTrainTypes) { - const result = await fetchTrainTypes({ - variables: { - stationId: lineStationId, - }, - }); - const fetchedTrainTypes = result.data?.stationTrainTypes ?? []; + if (fetchedTrainTypesData) { + const fetchedTrainTypes = + fetchedTrainTypesData.data?.stationTrainTypes ?? []; const designatedTrainTypeId = fetchedStations.find((s) => s.id === lineStationId)?.trainType?.id ?? null; diff --git a/src/hooks/useSimulationMode.test.tsx b/src/hooks/useSimulationMode.test.tsx index 00815f62c9..41d7d33016 100644 --- a/src/hooks/useSimulationMode.test.tsx +++ b/src/hooks/useSimulationMode.test.tsx @@ -11,6 +11,7 @@ import { import { YAMANOTE_LINE_ID } from '~/constants'; import * as useCurrentTrainTypeModule from '~/hooks/useCurrentTrainType'; import { useGraphQLQuery } from '~/hooks/useGraphQLQuery'; +import { useLoopLine } from '~/hooks/useLoopLine'; import { useSimulationMode } from '~/hooks/useSimulationMode'; import { GET_TRAIN_ROUTE } from '~/lib/graphql/queries'; import { store } from '~/store'; @@ -28,6 +29,7 @@ jest.mock('~/store/atoms/station', () => ({ stationAtom: { toString: () => 'stationAtom' }, stationsAtom: { toString: () => 'stationsAtom' }, selectedDirectionAtom: { toString: () => 'selectedDirectionAtom' }, + selectedBoundAtom: { toString: () => 'selectedBoundAtom' }, })); jest.mock('~/store/atoms/navigation', () => ({ @@ -36,6 +38,12 @@ jest.mock('~/store/atoms/navigation', () => ({ autoModeEnabledAtom: { toString: () => 'autoModeEnabledAtom' }, })); +jest.mock('~/store/atoms/speech', () => ({ + __esModule: true, + default: { toString: () => 'speechState' }, + resetFirstSpeechAtom: { toString: () => 'resetFirstSpeechAtom' }, +})); + jest.mock('~/store', () => ({ store: { get: jest.fn(() => null), @@ -199,6 +207,10 @@ describe('useSimulationMode', () => { jest.useFakeTimers(); jest.setSystemTime(new Date(100000)); + // 各テストは非ループ線を前提とする。ループ線テストで上書きした実装が + // 後続テストへ漏れないよう毎回明示的にリセットする。 + (useLoopLine as jest.Mock).mockReturnValue({ isLoopLine: false }); + jest .spyOn(useCurrentTrainTypeModule, 'useCurrentTrainType') .mockReturnValue(null); @@ -575,8 +587,8 @@ describe('useSimulationMode', () => { ); }); - it('終端駅到達後、先頭に戻ったときに速度プロファイルを最初から再生する', () => { - // 駅が1つだけ → nextStopStationがない → 即座に先頭リセット + it('終点到達後は即座に折り返さず終点で停車し続ける', () => { + // 駅が1つだけ → nextStopStationがない → 即座に終点扱い const stations = [mockStation(1, 1, 35.681, 139.767)]; setupAtomMocks( @@ -588,37 +600,168 @@ describe('useSimulationMode', () => { { autoModeEnabled: true } ); - // step内でstore.getが呼ばれる + // dwell処理内でstore.getが呼ばれる (store.get as jest.Mock).mockReturnValue( mockLocationObject(35.681, 139.767) ); // 速度プロファイルは空(駅が1つで次の駅がない)なので // interval tick 1: speeds=[], i(0)>=0 → dwellPending=true - // interval tick 2: dwell handler → nextSegment=-1 → 先頭に戻る - // interval tick 3: speeds=[] again → dwellPending=true - // 先頭に戻る際にchildIndexがリセットされていれば、 - // 毎回i=0から開始される(リセットされていないとiが進み続ける) + // interval tick 2以降: dwell handler → nextSegment=-1 → 終点で停車し + // TERMINAL_DWELL_TICKS に達するまで方面逆転せず待機し続ける。 + // 待機中は始発駅へワープせず、終点座標で speed=0 のまま留まる。 renderHook(() => useSimulationMode(), { wrapper: ({ children }) => {children}, }); - // 6秒分進める(複数回のリセットサイクルを経る) + // 6秒分進める(待機継続中) jest.advanceTimersByTime(6000); - // 先頭駅の位置が繰り返しセットされることを確認(リセットが正しく機能している) + // 終点座標で speed=0 の位置更新が繰り返しセットされることを確認 const locationSetCalls = (store.set as jest.Mock).mock.calls .filter((call) => call[0] === locationAtom) .map((call) => call[1]); - const resetCalls = locationSetCalls.filter( + const dwellCalls = locationSetCalls.filter( (loc) => loc?.coords?.latitude === stations[0].latitude && loc?.coords?.longitude === stations[0].longitude && loc?.coords?.speed === 0 ); - // 初期化 + dwellハンドラでの複数回リセット - expect(resetCalls.length).toBeGreaterThanOrEqual(2); + // 終点停車中の複数回の位置更新 + expect(dwellCalls.length).toBeGreaterThanOrEqual(2); + + // 待機時間(60ティック)未満では方面逆転(selectedDirection書き込み)は起きない + const directionSetCalls = (store.set as jest.Mock).mock.calls.filter( + (call) => call[0]?.toString?.() === 'selectedDirectionAtom' + ); + expect(directionSetCalls).toHaveLength(0); + }); + + it('終点で約1分停車したのち方面を逆転して折り返す', () => { + const stations = [ + mockStation(1, 1, 35.681, 139.767), + mockStation(2, 2, 35.691, 139.777), + ]; + + setupAtomMocks( + { + station: stations[0], + stations, + selectedDirection: 'INBOUND', + }, + { autoModeEnabled: true } + ); + + mockTrainRoute(stations); + + // 1駅1ティックで終点まで到達させる + jest + .spyOn(trainSpeedModule, 'generateTrainSpeedProfile') + .mockReturnValue([2000]); + + // resetFirstSpeechAtom は非ゼロの数値、それ以外(locationAtom)は位置オブジェクトを返す。 + // 非ゼロ(3)にすることで「現在値 + 1」を読んでいることを検証できる(固定値1だと通ってしまう)。 + (store.get as jest.Mock).mockImplementation((atom) => + atom?.toString?.() === 'resetFirstSpeechAtom' + ? 3 + : mockLocationObject(35.691, 139.777) + ); + + renderHook(() => useSimulationMode(), { + wrapper: ({ children }) => {children}, + }); + + // 終点到達 + 30秒程度の停車。まだ待機時間(約60秒)に満たないので折り返さない + jest.advanceTimersByTime(30000); + + let directionSetCalls = (store.set as jest.Mock).mock.calls.filter( + (call) => call[0]?.toString?.() === 'selectedDirectionAtom' + ); + expect(directionSetCalls).toHaveLength(0); + // 折り返す前は初回放送の再発火も起きない + expect( + (store.set as jest.Mock).mock.calls.filter( + (call) => call[0]?.toString?.() === 'resetFirstSpeechAtom' + ) + ).toHaveLength(0); + + // さらに進めて待機時間を超過させると方面(selectedDirection/selectedBound)が逆転する + jest.advanceTimersByTime(40000); + + directionSetCalls = (store.set as jest.Mock).mock.calls.filter( + (call) => call[0]?.toString?.() === 'selectedDirectionAtom' + ); + expect(directionSetCalls.length).toBeGreaterThanOrEqual(1); + // INBOUND → OUTBOUND へ逆転 + expect(directionSetCalls[0][1]).toBe('OUTBOUND'); + + // 折り返し後の行き先(selectedBound)も更新される + const boundSetCalls = (store.set as jest.Mock).mock.calls.filter( + (call) => call[0]?.toString?.() === 'selectedBoundAtom' + ); + expect(boundSetCalls.length).toBeGreaterThanOrEqual(1); + + // 折り返し時に初回放送(firstSpeech)が再発火する(resetFirstSpeechをインクリメント)。 + // 現在値3 + 1 = 4 が設定され、二重発火せず1回だけ呼ばれることを検証する。 + const resetFirstSpeechCalls = (store.set as jest.Mock).mock.calls.filter( + (call) => call[0]?.toString?.() === 'resetFirstSpeechAtom' + ); + expect(resetFirstSpeechCalls).toHaveLength(1); + expect(resetFirstSpeechCalls[0][1]).toBe(4); + }); + + it('ループ線では終点でも方面を逆転せず先頭に戻って周回を続ける', () => { + (useLoopLine as jest.Mock).mockReturnValue({ isLoopLine: true }); + + const stations = [ + mockStation(1, 1, 35.681, 139.767), + mockStation(2, 2, 35.691, 139.777), + ]; + + setupAtomMocks( + { + station: stations[1], + stations, + selectedDirection: 'INBOUND', + }, + { autoModeEnabled: true } + ); + + // ループ線 + INBOUND では進行順が reverse される([s2, s1]) + mockTrainRoute([stations[1], stations[0]]); + + jest + .spyOn(trainSpeedModule, 'generateTrainSpeedProfile') + .mockReturnValue([2000]); + + (store.get as jest.Mock).mockReturnValue( + mockLocationObject(35.691, 139.777) + ); + + renderHook(() => useSimulationMode(), { + wrapper: ({ children }) => {children}, + }); + + // 非ループ線なら折り返す待機時間を超過しても、ループ線では方面を逆転しない + jest.advanceTimersByTime(70000); + + const directionSetCalls = (store.set as jest.Mock).mock.calls.filter( + (call) => call[0]?.toString?.() === 'selectedDirectionAtom' + ); + expect(directionSetCalls).toHaveLength(0); + + // 先頭駅(周回の始点)へ戻る位置更新が行われている + const locationSetCalls = (store.set as jest.Mock).mock.calls + .filter((call) => call[0] === locationAtom) + .map((call) => call[1]); + const backToStartCalls = locationSetCalls.filter( + (loc) => + loc?.coords?.latitude === stations[1].latitude && + loc?.coords?.longitude === stations[1].longitude && + loc?.coords?.speed === 0 + ); + expect(backToStartCalls.length).toBeGreaterThanOrEqual(1); }); it('速度プロファイルの終端に達したら次のセグメントに移動する', () => { diff --git a/src/hooks/useSimulationMode.ts b/src/hooks/useSimulationMode.ts index 18d7c8b8c8..9ee5ca796b 100644 --- a/src/hooks/useSimulationMode.ts +++ b/src/hooks/useSimulationMode.ts @@ -11,8 +11,10 @@ import { GET_TRAIN_ROUTE } from '~/lib/graphql/queries'; import { store } from '~/store'; import { locationAtom } from '~/store/atoms/location'; import { autoModeEnabledAtom } from '~/store/atoms/navigation'; +import { resetFirstSpeechAtom } from '~/store/atoms/speech'; import { generateTrainSpeedProfile } from '~/utils/trainSpeed'; import { + selectedBoundAtom, selectedDirectionAtom, stationAtom, stationsAtom, @@ -23,6 +25,10 @@ import { useCurrentTrainType } from './useCurrentTrainType'; import { useGraphQLQuery } from './useGraphQLQuery'; import { useLoopLine } from './useLoopLine'; +// 終点到着後、方面を逆転して折り返すまでの待機時間。 +// step のインターバルが1秒間隔のため、ティック数(≒秒数)としてそのまま扱う。 +const TERMINAL_DWELL_TICKS = 60; + export const useSimulationMode = (): void => { const currentStation = useAtomValue(stationAtom); const rawStations = useAtomValue(stationsAtom); @@ -40,6 +46,11 @@ export const useSimulationMode = (): void => { const speedProfilesRef = useRef([]); const segmentProgressDistanceRef = useRef(0); const dwellPendingRef = useRef(false); + // 終点到着後、方面を逆転して折り返すまで終点で停車し続けたティック数 + const terminalDwellCountRef = useRef(0); + // 方面逆転中フラグ。新しい進行方向の速度プロファイルが再生成されるまで + // 終点で停車したまま待機し、旧方向のプロファイル/ジオメトリでの誤stepを防ぐ。 + const reversingRef = useRef(false); // 区間ごとの (waypoints, cumulativeDistances) キャッシュ。 // step() は毎秒呼ばれる。区間が変わらない限り cumulativeDistances は同じなので、 // waypoints 毎の getDistance / reduce を毎ティック走らせる必要は無い。 @@ -282,6 +293,9 @@ export const useSimulationMode = (): void => { childIndexRef.current = 0; segmentProgressDistanceRef.current = 0; dwellPendingRef.current = false; + terminalDwellCountRef.current = 0; + // 新しい方向の速度プロファイルが揃ったので折り返し待機を解除する + reversingRef.current = false; }, [maybeRevsersedStations, trainRouteData, resolveStartIndex]); const step = useCallback( @@ -427,6 +441,23 @@ export const useSimulationMode = (): void => { const speeds = speedProfilesRef.current[segmentIndexRef.current] ?? []; + // 方面逆転中は新方向の速度プロファイルが再生成されるまで終点で停車して待つ。 + // 旧方向のプロファイル/ジオメトリで step すると位置が飛ぶため、ここで待機する。 + if (reversingRef.current) { + const prev = store.get(locationAtom); + if (prev) { + store.set(locationAtom, { + timestamp: Date.now(), + coords: { + ...prev.coords, + speed: 0, + heading: null, + }, + }); + } + return; + } + if (dwellPendingRef.current) { const prev = store.get(locationAtom); if (prev) { @@ -442,27 +473,62 @@ export const useSimulationMode = (): void => { const nextSegmentIndex = speedProfilesRef.current.findIndex( (seg, idx) => seg.length > 0 && idx > segmentIndexRef.current ); + if (nextSegmentIndex === -1) { - const firstStation = maybeRevsersedStations[0]; - if ( - prev && - firstStation?.latitude != null && - firstStation?.longitude != null - ) { - store.set(locationAtom, { - timestamp: Date.now(), - coords: { - ...prev.coords, - latitude: firstStation.latitude, - longitude: firstStation.longitude, - speed: 0, - heading: null, - }, - }); + if (isLoopLine) { + // ループ線には折り返す終点が無いため、従来どおり先頭に戻って + // 同一方向のまま周回を続ける。 + const firstStation = maybeRevsersedStations[0]; + if ( + prev && + firstStation?.latitude != null && + firstStation?.longitude != null + ) { + store.set(locationAtom, { + timestamp: Date.now(), + coords: { + ...prev.coords, + latitude: firstStation.latitude, + longitude: firstStation.longitude, + speed: 0, + heading: null, + }, + }); + } + segmentIndexRef.current = 0; + childIndexRef.current = 0; + segmentProgressDistanceRef.current = 0; + dwellPendingRef.current = false; + return; + } + + // 終点に到達。すぐには折り返さず、約1分間そのまま停車してから + // 方面(selectedDirection / selectedBound)を逆転させて折り返す。 + if (terminalDwellCountRef.current < TERMINAL_DWELL_TICKS) { + terminalDwellCountRef.current += 1; + return; } + + // 待機完了 → 方面を逆転する。maybeRevsersedStations と trainRoute は + // どちらも selectedDirection に依存して再計算されるため、方向を反転すれば + // 終点始発の折り返し運転として自然にシミュレーションが継続する。 + terminalDwellCountRef.current = 0; + dwellPendingRef.current = false; + reversingRef.current = true; + const reversedDirection = + selectedDirection === 'INBOUND' ? 'OUTBOUND' : 'INBOUND'; + // 反転後の進行方向は現在の maybeRevsersedStations の逆順になるため、 + // 折り返し後の行き先(selectedBound)は現在の始発駅にあたる先頭要素。 + store.set(selectedBoundAtom, maybeRevsersedStations[0] ?? null); + store.set(selectedDirectionAtom, reversedDirection); + // 折り返し後は新しい行き先として初回放送(この電車は〜行きです)を再発火させる。 + // resetFirstSpeechAtom を進めると useTTS 側で firstSpeech が true に戻り、 + // 行き先変更 + 発車後(arrived=false)に初回TTSが改めて再生される。 + store.set(resetFirstSpeechAtom, store.get(resetFirstSpeechAtom) + 1); + return; } - segmentIndexRef.current = - nextSegmentIndex === -1 ? 0 : nextSegmentIndex; + + segmentIndexRef.current = nextSegmentIndex; childIndexRef.current = 0; segmentProgressDistanceRef.current = 0; dwellPendingRef.current = false; @@ -483,5 +549,5 @@ export const useSimulationMode = (): void => { return () => { clearInterval(intervalId); }; - }, [enabled, maybeRevsersedStations, selectedDirection, step]); + }, [enabled, isLoopLine, maybeRevsersedStations, selectedDirection, step]); }; diff --git a/src/hooks/useStartBackgroundLocationUpdates.test.tsx b/src/hooks/useStartBackgroundLocationUpdates.test.tsx index a8c0793e6c..e35df4d673 100644 --- a/src/hooks/useStartBackgroundLocationUpdates.test.tsx +++ b/src/hooks/useStartBackgroundLocationUpdates.test.tsx @@ -13,7 +13,6 @@ import { useStartBackgroundLocationUpdates } from './useStartBackgroundLocationU let mockNeedsJobSchedulerBypass = false; let mockSystemLowPowerMode = false; -let mockIsDevApp = false; jest.mock('../constants/native', () => ({ get NEEDS_JOBSCHEDULER_BYPASS() { return mockNeedsJobSchedulerBypass; @@ -23,11 +22,6 @@ jest.mock('../constants/native', () => ({ jest.mock('expo-battery', () => ({ useLowPowerMode: () => mockSystemLowPowerMode, })); -jest.mock('~/utils/isDevApp', () => ({ - get isDevApp() { - return mockIsDevApp; - }, -})); jest.mock('expo-location'); jest.mock('./useLocationPermissionsGranted'); jest.mock('~/store', () => ({ @@ -44,7 +38,7 @@ jest.mock('~/store/atoms/navigation', () => ({ default: {}, autoModeEnabledAtom: { toString: () => 'autoModeEnabledAtom' }, })); -jest.mock('~/store/atoms/experimental', () => ({ +jest.mock('~/store/atoms/battery', () => ({ powerSavingLocationEnabledAtom: { toString: () => 'powerSavingLocationEnabledAtom', }, @@ -92,7 +86,6 @@ describe('useStartBackgroundLocationUpdates', () => { mockAutoModeEnabled = false; mockPowerSavingLocationEnabled = false; mockSystemLowPowerMode = false; - mockIsDevApp = false; mockNeedsJobSchedulerBypass = false; mockStartLocationUpdatesAsync.mockResolvedValue(undefined); mockStopLocationUpdatesAsync.mockResolvedValue(undefined); @@ -115,7 +108,7 @@ describe('useStartBackgroundLocationUpdates', () => { ...LOCATION_TASK_OPTIONS, activityType: Location.ActivityType.OtherNavigation, foregroundService: expect.objectContaining({ - killServiceOnDestroy: false, + killServiceOnDestroy: true, }), }) ); @@ -389,9 +382,8 @@ describe('useStartBackgroundLocationUpdates', () => { }); describe('power saving location mode', () => { - test('should apply the full power-saving profile to background updates when enabled', async () => { + test('should lower accuracy and allow automatic pauses for background updates when enabled', async () => { mockPowerSavingLocationEnabled = true; - mockIsDevApp = true; mockUseLocationPermissionsGranted.mockReturnValue(true); renderHook(() => useStartBackgroundLocationUpdates()); @@ -402,7 +394,8 @@ describe('useStartBackgroundLocationUpdates', () => { LOCATION_TASK_NAME, expect.objectContaining({ ...LOCATION_TASK_OPTIONS_POWER_SAVING, - accuracy: Location.Accuracy.High, + accuracy: Location.Accuracy.Balanced, + pausesUpdatesAutomatically: true, activityType: Location.ActivityType.OtherNavigation, foregroundService: expect.objectContaining({ killServiceOnDestroy: true, @@ -411,7 +404,7 @@ describe('useStartBackgroundLocationUpdates', () => { ); }); - test('should request default accuracy for background updates when disabled', async () => { + test('should keep the default profile for background updates when disabled', async () => { mockPowerSavingLocationEnabled = false; mockUseLocationPermissionsGranted.mockReturnValue(true); @@ -419,38 +412,18 @@ describe('useStartBackgroundLocationUpdates', () => { await new Promise(process.nextTick); - expect(mockStartLocationUpdatesAsync).toHaveBeenCalledWith( - LOCATION_TASK_NAME, - expect.objectContaining({ accuracy: Location.Accuracy.Highest }) - ); - }); - - test('should enable the power-saving profile in the dev app while the system low-power mode is active', async () => { - mockPowerSavingLocationEnabled = false; - mockSystemLowPowerMode = true; - mockIsDevApp = true; - mockUseLocationPermissionsGranted.mockReturnValue(true); - - renderHook(() => useStartBackgroundLocationUpdates()); - - await new Promise(process.nextTick); - expect(mockStartLocationUpdatesAsync).toHaveBeenCalledWith( LOCATION_TASK_NAME, expect.objectContaining({ - ...LOCATION_TASK_OPTIONS_POWER_SAVING, accuracy: Location.Accuracy.High, - foregroundService: expect.objectContaining({ - killServiceOnDestroy: true, - }), + pausesUpdatesAutomatically: false, }) ); }); - test('should keep the default profile in production while the system low-power mode is active', async () => { + test('should enable the power-saving profile while the system low-power mode is active', async () => { mockPowerSavingLocationEnabled = false; mockSystemLowPowerMode = true; - mockIsDevApp = false; mockUseLocationPermissionsGranted.mockReturnValue(true); renderHook(() => useStartBackgroundLocationUpdates()); @@ -460,17 +433,15 @@ describe('useStartBackgroundLocationUpdates', () => { expect(mockStartLocationUpdatesAsync).toHaveBeenCalledWith( LOCATION_TASK_NAME, expect.objectContaining({ - ...LOCATION_TASK_OPTIONS, - foregroundService: expect.objectContaining({ - killServiceOnDestroy: false, - }), + ...LOCATION_TASK_OPTIONS_POWER_SAVING, + accuracy: Location.Accuracy.Balanced, + pausesUpdatesAutomatically: true, }) ); }); - test('should apply the full power-saving profile to foreground watchPositionAsync when enabled', async () => { + test('should apply the power-saving accuracy to foreground watchPositionAsync when enabled', async () => { mockPowerSavingLocationEnabled = true; - mockIsDevApp = true; mockUseLocationPermissionsGranted.mockReturnValue(false); renderHook(() => useStartBackgroundLocationUpdates()); @@ -483,22 +454,7 @@ describe('useStartBackgroundLocationUpdates', () => { ); }); - test('should ignore the experimental setting outside the dev app', async () => { - mockPowerSavingLocationEnabled = true; - mockIsDevApp = false; - mockUseLocationPermissionsGranted.mockReturnValue(false); - - renderHook(() => useStartBackgroundLocationUpdates()); - - await new Promise(process.nextTick); - - expect(mockWatchPositionAsync).toHaveBeenCalledWith( - LOCATION_WATCH_OPTIONS, - expect.any(Function) - ); - }); - - test('should apply default accuracy to foreground watchPositionAsync when disabled', async () => { + test('should apply the default accuracy to foreground watchPositionAsync when disabled', async () => { mockPowerSavingLocationEnabled = false; mockUseLocationPermissionsGranted.mockReturnValue(false); diff --git a/src/hooks/useStartBackgroundLocationUpdates.ts b/src/hooks/useStartBackgroundLocationUpdates.ts index 7b22089174..6b4b6e6e91 100644 --- a/src/hooks/useStartBackgroundLocationUpdates.ts +++ b/src/hooks/useStartBackgroundLocationUpdates.ts @@ -3,11 +3,10 @@ import * as Location from 'expo-location'; import { useAtomValue } from 'jotai'; import { useEffect } from 'react'; import { store } from '~/store'; -import { powerSavingLocationEnabledAtom } from '~/store/atoms/experimental'; +import { powerSavingLocationEnabledAtom } from '~/store/atoms/battery'; import { backgroundLocationTrackingAtom } from '~/store/atoms/location'; import { autoModeEnabledAtom } from '~/store/atoms/navigation'; import { handleTrackingLocation } from '~/utils/handleTrackingLocation'; -import { isDevApp } from '~/utils/isDevApp'; import { LOCATION_START_MAX_RETRIES, LOCATION_START_RETRY_BASE_DELAY_MS, @@ -30,15 +29,16 @@ export const useStartBackgroundLocationUpdates = () => { const bgPermGranted = useLocationPermissionsGranted(); const autoModeEnabled = useAtomValue(autoModeEnabledAtom); const systemLowPowerMode = Battery.useLowPowerMode(); - // 省電力測位モード(実験的機能)。精度と配信頻度を下げ、停車中の自動休止を許可する。 - // 選択するオブジェクトはモジュール定数なので、effect依存でも参照が安定する。 + // 省電力測位モード。精度をBalancedへ下げ、停車中の測位自動休止(iOSのみ)を + // 許可する。旧プロファイルのHigh精度・更新間隔の緩和は実車検証を経て既定値へ + // 昇格済み(constants/location.ts)。 const powerSavingSettingEnabled = useAtomValue( powerSavingLocationEnabledAtom ); - // 試験用機能のためdevアプリだけで有効化する。手動設定に加えて、 - // 端末の省電力モード中も自動的に同じプロファイルへ切り替える。 - const powerSavingEnabled = - isDevApp && (powerSavingSettingEnabled || systemLowPowerMode); + // 「バッテリー」設定でONにしたときに加え、端末の省電力モード中も自動的に + // 同じプロファイルへ切り替える。 + const powerSavingEnabled = powerSavingSettingEnabled || systemLowPowerMode; + // 選択するオブジェクトはモジュール定数なので、effect依存でも参照が安定する。 const watchOptions = powerSavingEnabled ? LOCATION_WATCH_OPTIONS_POWER_SAVING : LOCATION_WATCH_OPTIONS; @@ -91,10 +91,12 @@ export const useStartBackgroundLocationUpdates = () => { foregroundService: { notificationTitle: translate('bgAlertTitle'), notificationBody: translate('bgAlertContent'), - // Androidの履歴画面からアプリのタスクが削除されたとき、Expoの - // フォアグラウンド測位サービスと常駐通知を停止する。このオプションは - // stopLocationUpdatesAsyncを呼ばず、測位タスク自体の登録解除は保証しない。 - killServiceOnDestroy: powerSavingEnabled, + // タスクキル後はヘッドレスタスクがin-memory状態を更新するだけで + // ユーザーへ何も提供できないため、Androidの履歴画面からアプリが + // 削除されたときは常にフォアグラウンド測位サービスと常駐通知を + // 停止する。このオプションはstopLocationUpdatesAsyncを呼ばず、 + // 測位タスク自体の登録解除は保証しない(起動時のクリーンアップで対処)。 + killServiceOnDestroy: true, }, }); // クリーンアップがstartの完了前に実行された場合、 @@ -166,13 +168,7 @@ export const useStartBackgroundLocationUpdates = () => { ); }); }; - }, [ - autoModeEnabled, - bgPermGranted, - powerSavingEnabled, - taskOptions, - watchOptions, - ]); + }, [autoModeEnabled, bgPermGranted, taskOptions, watchOptions]); useEffect(() => { let watchPositionSub: Location.LocationSubscription | null = null; diff --git a/src/hooks/useTTSFeatureEnabled.ts b/src/hooks/useTTSFeatureEnabled.ts new file mode 100644 index 0000000000..df7ae7edc7 --- /dev/null +++ b/src/hooks/useTTSFeatureEnabled.ts @@ -0,0 +1,9 @@ +import { useSyncExternalStore } from 'react'; +import { isTTSFeatureEnabled, subscribeRemoteConfig } from '~/lib/remoteConfig'; + +// TTS機能キルスイッチ(tts_enabled)のリアクティブ版。setupRemoteConfig は起動時に +// 非同期で完了するため、同期読みだけではコールドスタート時にフォールバック(true)で +// 描画された後、false 到着時の再レンダーが保証されない。キャッシュ更新を購読して +// 確実に再評価させる。 +export const useTTSFeatureEnabled = (): boolean => + useSyncExternalStore(subscribeRemoteConfig, isTTSFeatureEnabled); diff --git a/src/hooks/useTelemetrySender.appLaunch.test.tsx b/src/hooks/useTelemetrySender.appLaunch.test.tsx new file mode 100644 index 0000000000..3cb1e6373d --- /dev/null +++ b/src/hooks/useTelemetrySender.appLaunch.test.tsx @@ -0,0 +1,133 @@ +import { act, renderHook, waitFor } from '@testing-library/react-native'; +import { + findInteractionEventCalls, + setupTelemetrySenderMocks, + TELEMETRY_TEST_BASE_URL, + TelemetryTestWrapper, + useTelemetryEnabled, + useTelemetrySender, +} from '~/utils/test/telemetrySenderTestSetup'; + +let mockFetch: jest.Mock; + +const findAppLaunchCalls = () => + findInteractionEventCalls(mockFetch, 'app_launch'); + +// NOTE: app_launchの一度きり送信はモジュールレベルのフラグで管理されるため、 +// このdescribe内のテストは記述順に依存する(disabled → 失敗時再送 → 初回送信 → 再送なし) +describe('useTelemetrySender (app_launch event)', () => { + beforeEach(() => { + mockFetch = setupTelemetrySenderMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should not send app_launch while telemetry is disabled', async () => { + (useTelemetryEnabled as jest.Mock).mockReturnValue(false); + + renderHook( + () => useTelemetrySender(false, TELEMETRY_TEST_BASE_URL, 'test-token'), + { wrapper: TelemetryTestWrapper } + ); + + await new Promise((r) => setTimeout(r, 30)); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + test('should reset the sent flag and retry app_launch after a failure', async () => { + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + mockFetch.mockRejectedValue(new Error('Network error')); + + const first = renderHook( + () => useTelemetrySender(false, TELEMETRY_TEST_BASE_URL, 'test-token'), + { wrapper: TelemetryTestWrapper } + ); + + await waitFor( + () => { + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to send interaction event:', + expect.any(Error) + ); + }, + { timeout: 2000 } + ); + // 失敗後のフラグ戻し(.then)まで確実に流してからアンマウントする + await act(async () => { + await Promise.resolve(); + }); + first.unmount(); + + // フラグが戻っているため、新しいインスタンスが再送を試みる + consoleSpy.mockClear(); + const second = renderHook( + () => useTelemetrySender(false, TELEMETRY_TEST_BASE_URL, 'test-token'), + { wrapper: TelemetryTestWrapper } + ); + + await waitFor( + () => { + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to send interaction event:', + expect.any(Error) + ); + }, + { timeout: 2000 } + ); + // 2回目の失敗のフラグ戻しも流しきってからテストを終える + // (後続テストへ非同期のフラグ操作が漏れるのを防ぐ) + await act(async () => { + await Promise.resolve(); + }); + second.unmount(); + + consoleSpy.mockRestore(); + }); + + test('should send app_launch once when telemetry becomes enabled', async () => { + renderHook( + () => useTelemetrySender(false, TELEMETRY_TEST_BASE_URL, 'test-token'), + { wrapper: TelemetryTestWrapper } + ); + + await waitFor( + () => { + const calls = findAppLaunchCalls(); + expect(calls.length).toBe(1); + const input = JSON.parse(calls[0][1].body).variables.input; + expect(input.sessionId).toBe('test-session-id'); + expect(input.device).toBe('MockDevice'); + expect(input.appVersion).toBe('1.0.0(42)'); + expect(input.platform).toBe('ios'); + expect(input.channel).toBe('production'); + expect(input.properties).toBeNull(); + }, + { timeout: 2000 } + ); + // 送信成功の確定(.then)まで流し、フラグを確実にtrueで固定する + await act(async () => { + await Promise.resolve(); + }); + }); + + test('should not send app_launch again from other hook instances', async () => { + renderHook( + () => useTelemetrySender(false, TELEMETRY_TEST_BASE_URL, 'test-token'), + { wrapper: TelemetryTestWrapper } + ); + renderHook( + () => useTelemetrySender(true, TELEMETRY_TEST_BASE_URL, 'test-token'), + { wrapper: TelemetryTestWrapper } + ); + + await new Promise((r) => setTimeout(r, 30)); + + expect(findAppLaunchCalls().length).toBe(0); + }); +}); diff --git a/src/hooks/useTelemetrySender.enabled.test.tsx b/src/hooks/useTelemetrySender.enabled.test.tsx index c2669a808e..44fb98f1d1 100644 --- a/src/hooks/useTelemetrySender.enabled.test.tsx +++ b/src/hooks/useTelemetrySender.enabled.test.tsx @@ -101,11 +101,21 @@ const findGraphQLCall = (mutationName: string) => describe('useTelemetrySender', () => { beforeEach(() => { + // どのmutationに対しても有効なレスポンスを返す。app_launchの自動送信が + // 失敗扱いになると送信済みフラグが戻り、後続テストのmockResolvedValueOnce等が + // app_launch側に消費されてしまうため、成功レスポンスでフラグを確定させる mockFetch = jest.fn().mockResolvedValue({ ok: true, status: 200, statusText: 'OK', - json: () => Promise.resolve({ ok: true }), + json: () => + Promise.resolve({ + data: { + sendLogEvent: { sessionId: 'test-session-id' }, + sendLocation: { sessionId: 'test-session-id', warning: null }, + sendInteractionEvent: { sessionId: 'test-session-id' }, + }, + }), }); global.fetch = mockFetch; diff --git a/src/hooks/useTelemetrySender.interaction.test.tsx b/src/hooks/useTelemetrySender.interaction.test.tsx new file mode 100644 index 0000000000..e5d6027a5f --- /dev/null +++ b/src/hooks/useTelemetrySender.interaction.test.tsx @@ -0,0 +1,194 @@ +import { act, renderHook, waitFor } from '@testing-library/react-native'; +import { + findInteractionEventCalls, + setupTelemetrySenderMocks, + TELEMETRY_TEST_BASE_URL, + TelemetryTestWrapper, + useTelemetryEnabled, + useTelemetrySender, +} from '~/utils/test/telemetrySenderTestSetup'; + +let mockFetch: jest.Mock; + +const findInteractionCall = (eventName: string) => + findInteractionEventCalls(mockFetch, eventName)[0]; + +describe('useTelemetrySender (interaction events)', () => { + beforeEach(() => { + mockFetch = setupTelemetrySenderMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should send interaction event via GraphQL sendInteractionEvent mutation', async () => { + const { result } = renderHook( + () => useTelemetrySender(false, TELEMETRY_TEST_BASE_URL, 'test-token'), + { wrapper: TelemetryTestWrapper } + ); + + await act(async () => { + result.current.sendInteractionEvent('tab_change', { + tabName: 'settings', + index: 2, + fromUser: true, + }); + await Promise.resolve(); + }); + + await waitFor( + () => { + const call = findInteractionCall('tab_change'); + expect(call).toBeDefined(); + const input = JSON.parse(call[1].body).variables.input; + expect(input.sessionId).toBe('test-session-id'); + expect(input.device).toBe('MockDevice'); + expect(input.appVersion).toBe('1.0.0(42)'); + expect(input.platform).toBe('ios'); + expect(input.channel).toBe('production'); + expect(typeof input.timestamp).toBe('number'); + expect(input.properties).toEqual({ + tabName: 'settings', + index: 2, + fromUser: true, + }); + }, + { timeout: 2000 } + ); + }); + + test('should send null properties when omitted', async () => { + const { result } = renderHook( + () => useTelemetrySender(false, TELEMETRY_TEST_BASE_URL, 'test-token'), + { wrapper: TelemetryTestWrapper } + ); + + await act(async () => { + result.current.sendInteractionEvent('screen_view'); + await Promise.resolve(); + }); + + await waitFor( + () => { + const call = findInteractionCall('screen_view'); + expect(call).toBeDefined(); + expect(JSON.parse(call[1].body).variables.input.properties).toBeNull(); + }, + { timeout: 2000 } + ); + }); + + test('should include Authorization header with token', async () => { + const { result } = renderHook( + () => useTelemetrySender(false, TELEMETRY_TEST_BASE_URL, 'test-token'), + { wrapper: TelemetryTestWrapper } + ); + + await act(async () => { + result.current.sendInteractionEvent('tab_change'); + await Promise.resolve(); + }); + + await waitFor( + () => { + const call = findInteractionCall('tab_change'); + expect(call).toBeDefined(); + expect(call[1].headers.Authorization).toBe('Bearer test-token'); + expect(call[1].headers['Content-Type']).toBe('application/json'); + }, + { timeout: 2000 } + ); + }); + + test('should not send interaction event if telemetry is disabled', async () => { + (useTelemetryEnabled as jest.Mock).mockReturnValue(false); + + const { result } = renderHook( + () => useTelemetrySender(false, TELEMETRY_TEST_BASE_URL, 'test-token'), + { wrapper: TelemetryTestWrapper } + ); + + await act(async () => { + result.current.sendInteractionEvent('tab_change'); + await Promise.resolve(); + }); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + test('should not send interaction event if baseUrl is not provided', async () => { + const { result } = renderHook(() => useTelemetrySender(false, ''), { + wrapper: TelemetryTestWrapper, + }); + + await act(async () => { + result.current.sendInteractionEvent('tab_change'); + await Promise.resolve(); + }); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + test('should warn when API returns error', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => + Promise.resolve({ data: null, errors: [{ message: 'Server error' }] }), + }); + + const { result } = renderHook( + () => useTelemetrySender(false, TELEMETRY_TEST_BASE_URL, 'test-token'), + { wrapper: TelemetryTestWrapper } + ); + + await act(async () => { + result.current.sendInteractionEvent('tab_change'); + await Promise.resolve(); + }); + + await waitFor( + () => { + expect(consoleSpy).toHaveBeenCalledWith( + 'Interaction event API error:', + 'Server error' + ); + }, + { timeout: 2000 } + ); + + consoleSpy.mockRestore(); + }); + + test('should handle fetch error gracefully', async () => { + const consoleSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + mockFetch.mockRejectedValue(new Error('Network error')); + + const { result } = renderHook( + () => useTelemetrySender(false, TELEMETRY_TEST_BASE_URL, 'test-token'), + { wrapper: TelemetryTestWrapper } + ); + + await act(async () => { + result.current.sendInteractionEvent('tab_change'); + await Promise.resolve(); + }); + + await waitFor( + () => { + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to send interaction event:', + expect.any(Error) + ); + }, + { timeout: 2000 } + ); + + consoleSpy.mockRestore(); + }); +}); diff --git a/src/hooks/useTelemetrySender.ts b/src/hooks/useTelemetrySender.ts index fb5a107e41..87c2f8b2af 100644 --- a/src/hooks/useTelemetrySender.ts +++ b/src/hooks/useTelemetrySender.ts @@ -90,6 +90,22 @@ const SendLocationResponse = z const TelemetryPlatform = z.enum(['ios', 'android', 'macos', 'unknown']); type TelemetryPlatform = z.infer; +// テレメトリ基盤がセッション単位でイベントを紐付けるためのID。 +// フックは複数コンポーネントから使われるため、インスタンス毎ではなく +// アプリプロセス全体で共有し、位置情報・ログ・インタラクションを突合可能にする +let telemetrySessionId: string | null = null; +const getOrCreateSessionId = (): string => { + if (telemetrySessionId == null) { + telemetrySessionId = Crypto.randomUUID(); + } + return telemetrySessionId; +}; + +// アプリプロセスごとに1回だけapp_launchイベントを送るためのフラグ。 +// telemetryEnabledはPermittedがMMKVからatomへ復元するまでfalseのため、 +// マウント時ではなく有効化を検知した時点で送信する +let hasAppLaunchEventBeenSent = false; + const getTelemetryPlatform = (): TelemetryPlatform => { switch (Platform.OS) { case 'ios': @@ -139,14 +155,59 @@ const SendLogEventResponse = z message: 'Either data.sendLogEvent or non-empty errors is required', }); +// テレメトリ基盤のGraphQL Propertiesスカラーに対応。 +// フラットなmapのみ許容され、ネストしたオブジェクトや配列は基盤側で拒否される +const InteractionEventProperties = z.record( + z.string(), + z.union([z.string(), z.number(), z.boolean(), z.null()]) +); +export type InteractionEventProperties = z.infer< + typeof InteractionEventProperties +>; + +// テレメトリ基盤のGraphQL InteractionEventInputに対応 +const InteractionEventInput = z.object({ + sessionId: z.string().min(1), + device: z.string().nullable().optional(), + appVersion: z.string().min(1), + platform: TelemetryPlatform, + channel: z.enum(['production', 'canary']), + timestamp: z.number(), + eventName: z.string().min(1), + properties: InteractionEventProperties.nullable().optional(), +}); + +const SEND_INTERACTION_EVENT_MUTATION = ` + mutation SendInteractionEvent($input: InteractionEventInput!) { + sendInteractionEvent(input: $input) { + sessionId + } + } +`; + +const SendInteractionEventResponse = z + .object({ + data: z + .object({ + sendInteractionEvent: z.object({ + sessionId: z.string(), + }), + }) + .nullable() + .optional(), + errors: z.array(z.object({ message: z.string() })).optional(), + }) + // {}のような空レスポンスを不正として弾く + .refine((res) => res.data != null || (res.errors?.length ?? 0) > 0, { + message: 'Either data.sendInteractionEvent or non-empty errors is required', + }); + export const useTelemetrySender = ( sendTelemetryAutomatically = false, baseUrl = EXPERIMENTAL_TELEMETRY_ENDPOINT_URL, token = EXPERIMENTAL_TELEMETRY_TOKEN ) => { const lastSentTelemetryRef = useRef(0); - // テレメトリ基盤がセッション単位で位置情報を紐付けるためのID。初回送信時に生成する - const sessionIdRef = useRef(null); const station = useCurrentStation(); const line = useCurrentLine(); @@ -172,12 +233,84 @@ export const useTelemetrySender = ( return 'moving'; }, [arrivedFromState, approachingFromState, passing]); - const getSessionId = useCallback(() => { - if (sessionIdRef.current == null) { - sessionIdRef.current = Crypto.randomUUID(); - } - return sessionIdRef.current; - }, []); + // 戻り値は送信の成否。呼び出し元が失敗時の再送制御をできるようにする + const sendInteractionEvent = useCallback( + async ( + eventName: string, + properties?: InteractionEventProperties + ): Promise => { + if (!isTelemetryEnabled || !baseUrl) { + return false; + } + + const payload = InteractionEventInput.safeParse({ + sessionId: getOrCreateSessionId(), + device: Device.modelName ?? 'unknown', + appVersion: `${Application.nativeApplicationVersion}(${Application.nativeBuildVersion})`, + platform: getTelemetryPlatform(), + channel: isDevApp ? 'canary' : 'production', + timestamp: Date.now(), + eventName, + properties: properties ?? null, + }); + + if (payload.error) { + console.error('Invalid interaction event payload:', payload.error); + return false; + } + + try { + const response = await fetch(`${baseUrl}/graphql`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + query: SEND_INTERACTION_EVENT_MUTATION, + variables: { input: payload.data }, + }), + }); + + if (!response.ok) { + console.error( + `HTTP error: ${response.status} ${response.statusText}` + ); + return false; + } + + let json: unknown; + try { + json = await response.json(); + } catch { + console.error('Failed to parse response JSON'); + return false; + } + + const result = SendInteractionEventResponse.safeParse(json); + if (!result.success) { + console.error( + 'Invalid interaction event response:', + result.error, + json + ); + return false; + } + if (result.data.errors?.length) { + console.warn( + 'Interaction event API error:', + result.data.errors.map((e) => e.message).join(', ') + ); + } + // サーバ側でデータとして受理されたことをもって送信成功とみなす + return result.data.data?.sendInteractionEvent != null; + } catch (error) { + console.error('Failed to send interaction event:', error); + return false; + } + }, + [isTelemetryEnabled, baseUrl, token] + ); const sendLog = useCallback( async ( @@ -191,7 +324,7 @@ export const useTelemetrySender = ( const now = Date.now(); const payload = LogEventInput.safeParse({ - sessionId: getSessionId(), + sessionId: getOrCreateSessionId(), device: Device.modelName ?? 'unknown', appVersion: `${Application.nativeApplicationVersion}(${Application.nativeBuildVersion})`, platform: getTelemetryPlatform(), @@ -250,7 +383,7 @@ export const useTelemetrySender = ( console.error('Failed to send log:', error); } }, - [isTelemetryEnabled, baseUrl, token, getSessionId] + [isTelemetryEnabled, baseUrl, token] ); const sendTelemetry = useCallback(async () => { @@ -283,7 +416,7 @@ export const useTelemetrySender = ( } const payload = LocationEventInput.safeParse({ - sessionId: getSessionId(), + sessionId: getOrCreateSessionId(), device: Device.modelName ?? 'unknown', state, lineId: line.id, @@ -362,7 +495,6 @@ export const useTelemetrySender = ( station?.id, baseUrl, token, - getSessionId, ]); useEffect(() => { @@ -373,5 +505,22 @@ export const useTelemetrySender = ( sendTelemetry(); }, [sendTelemetry, sendTelemetryAutomatically, isTelemetryEnabled]); - return { sendLog }; + // アプリ起動のインタラクションイベント。設定復元によりtelemetryEnabledが + // trueへ変わった時点で、プロセス内のどのインスタンスからでも1回だけ送る + useEffect(() => { + if (!isTelemetryEnabled || !baseUrl || hasAppLaunchEventBeenSent) { + return; + } + + hasAppLaunchEventBeenSent = true; + sendInteractionEvent('app_launch').then((sent) => { + if (!sent) { + // 起動直後の通信不調などで失敗した場合はフラグを戻し、 + // 後続のマウントや設定変更のタイミングで再送できるようにする + hasAppLaunchEventBeenSent = false; + } + }); + }, [isTelemetryEnabled, baseUrl, sendInteractionEvent]); + + return { sendLog, sendInteractionEvent }; }; diff --git a/src/lib/remoteConfig.test.ts b/src/lib/remoteConfig.test.ts index eade0a76d6..05445bdf1d 100644 --- a/src/lib/remoteConfig.test.ts +++ b/src/lib/remoteConfig.test.ts @@ -1,15 +1,14 @@ import { MAX_PERMIT_ACCURACY } from '~/constants/location'; -import { store } from '~/store'; -import { etaAssistManualEnabledAtom } from '~/store/atoms/experimental'; import { getEtaFallbackArrivalConfirmMarginSec, getEtaFallbackMaxDurationMin, getMaxPermitAccuracy, isEtaAssistEnabled, - isEtaAssistRemoteEnabled, isForceNotArrivedOnLowAccuracyEnabled, + isTTSFeatureEnabled, resetRemoteConfigCache, setupRemoteConfig, + subscribeRemoteConfig, } from './remoteConfig'; jest.mock('./workerApi', () => ({ @@ -31,8 +30,6 @@ const mockRemoteConfig = (body: unknown, ok = true) => { afterEach(() => { jest.clearAllMocks(); resetRemoteConfigCache(); - // 手動トグルは共有ストアに残るため、テスト間の汚染を防ぐべく毎回戻す。 - store.set(etaAssistManualEnabledAtom, false); }); afterAll(() => { @@ -84,48 +81,30 @@ describe('isForceNotArrivedOnLowAccuracyEnabled', () => { }); }); -describe('isEtaAssistRemoteEnabled(マスタースイッチ)', () => { +describe('isEtaAssistEnabled(Remoteマスタースイッチのみで判定)', () => { it('falls back to false before setup', () => { - expect(isEtaAssistRemoteEnabled()).toBe(false); + expect(isEtaAssistEnabled()).toBe(false); }); - it('returns the remote boolean after setup', async () => { + it('RemoteがONなら自動的に有効(手動トグルなし)', async () => { mockRemoteConfig({ max_permit_accuracy: 1500, force_not_arrived_on_low_accuracy: true, eta_assist_enabled: true, }); await setupRemoteConfig(); - expect(isEtaAssistRemoteEnabled()).toBe(true); - }); - - it('falls back to false when the boolean is missing', async () => { - mockRemoteConfig({ max_permit_accuracy: 1500 }); - await setupRemoteConfig(); - expect(isEtaAssistRemoteEnabled()).toBe(false); - }); -}); - -describe('isEtaAssistEnabled(Remote AND 手動トグル)', () => { - it('Remoteと手動トグルの両方がONのときだけ有効', async () => { - mockRemoteConfig({ eta_assist_enabled: true }); - await setupRemoteConfig(); - // Remoteがtrueでも手動トグルOFFなら無効 - expect(isEtaAssistEnabled()).toBe(false); - // 手動トグルONで有効 - store.set(etaAssistManualEnabledAtom, true); expect(isEtaAssistEnabled()).toBe(true); }); - it('Remoteがfalseなら手動トグルONでも無効(マスターOFF)', async () => { - store.set(etaAssistManualEnabledAtom, true); - mockRemoteConfig({ eta_assist_enabled: false }); + it('RemoteがOFFなら無効(サーバー側キルスイッチ)', async () => { + mockRemoteConfig({ max_permit_accuracy: 1500, eta_assist_enabled: false }); await setupRemoteConfig(); expect(isEtaAssistEnabled()).toBe(false); }); - it('セットアップ前(Remote未取得=false)は手動トグルONでも無効', () => { - store.set(etaAssistManualEnabledAtom, true); + it('falls back to false when the boolean is missing', async () => { + mockRemoteConfig({ max_permit_accuracy: 1500 }); + await setupRemoteConfig(); expect(isEtaAssistEnabled()).toBe(false); }); }); @@ -187,6 +166,62 @@ describe('getEtaFallbackMaxDurationMin', () => { }); }); +describe('isTTSFeatureEnabled(サーバー側キルスイッチ)', () => { + it('falls back to true before setup', () => { + expect(isTTSFeatureEnabled()).toBe(true); + }); + + it('returns the remote boolean after setup', async () => { + mockRemoteConfig({ max_permit_accuracy: 1500, tts_enabled: false }); + await setupRemoteConfig(); + expect(isTTSFeatureEnabled()).toBe(false); + }); + + it('RemoteがONなら有効', async () => { + mockRemoteConfig({ max_permit_accuracy: 1500, tts_enabled: true }); + await setupRemoteConfig(); + expect(isTTSFeatureEnabled()).toBe(true); + }); + + it('falls back to true when the boolean is missing', async () => { + mockRemoteConfig({ max_permit_accuracy: 1500 }); + await setupRemoteConfig(); + expect(isTTSFeatureEnabled()).toBe(true); + }); + + it('falls back to true when fetching remote config fails', async () => { + mockRemoteConfig({}, false); + await expect(setupRemoteConfig()).rejects.toThrow( + 'remote config fetch failed: 503' + ); + expect(isTTSFeatureEnabled()).toBe(true); + }); +}); + +describe('subscribeRemoteConfig(キャッシュ更新の購読)', () => { + it('setupRemoteConfig 完了時にリスナーへ通知される', async () => { + const listener = jest.fn(); + const unsubscribe = subscribeRemoteConfig(listener); + + mockRemoteConfig({ max_permit_accuracy: 1500, tts_enabled: false }); + await setupRemoteConfig(); + + expect(listener).toHaveBeenCalled(); + unsubscribe(); + }); + + it('購読解除後は通知されない', async () => { + const listener = jest.fn(); + const unsubscribe = subscribeRemoteConfig(listener); + unsubscribe(); + + mockRemoteConfig({ max_permit_accuracy: 1500, tts_enabled: false }); + await setupRemoteConfig(); + + expect(listener).not.toHaveBeenCalled(); + }); +}); + describe('setupRemoteConfig', () => { it('fetches the remote config endpoint', async () => { mockRemoteConfig({ diff --git a/src/lib/remoteConfig.ts b/src/lib/remoteConfig.ts index 07384eaa08..c62d5b500d 100644 --- a/src/lib/remoteConfig.ts +++ b/src/lib/remoteConfig.ts @@ -1,6 +1,4 @@ import { MAX_PERMIT_ACCURACY } from '~/constants/location'; -import { store } from '~/store'; -import { etaAssistManualEnabledAtom } from '~/store/atoms/experimental'; import { workerUrl } from './workerApi'; // Cloudflare Worker(/config/remote) 配信の設定キー。Worker 側のレスポンスキーと一致させる。 @@ -18,6 +16,9 @@ export const REMOTE_CONFIG_KEYS = { // ETAフォールバックを継続してよい最大時間(分)。GPS喪失がこれを超えて続く場合は // フォールバックを打ち切り、不確実な推定に依存し続けないようにする。 ETA_FALLBACK_MAX_DURATION_MIN: 'eta_fallback_max_duration_min', + // TTS(自動アナウンス)機能の有効/無効。false のとき設定画面のTTSトグルを無効化し、 + // 音声合成バックエンド障害時などにサーバー側から機能を止められるようにする。 + TTS_ENABLED: 'tts_enabled', } as const; type RemoteConfigResponse = { @@ -26,6 +27,7 @@ type RemoteConfigResponse = { eta_assist_enabled?: boolean; eta_fallback_arrival_confirm_margin_sec?: number; eta_fallback_max_duration_min?: number; + tts_enabled?: boolean; }; // 精度超過時に到着判定を未到着へ強制する機能のフォールバック既定値。 @@ -39,6 +41,10 @@ const ETA_FALLBACK_ARRIVAL_CONFIRM_MARGIN_SEC_FALLBACK = 30; // ETAフォールバックを継続してよい最大時間(分)のフォールバック既定値。 const ETA_FALLBACK_MAX_DURATION_MIN_FALLBACK = 30; +// TTS機能のフォールバック既定値。キルスイッチ用途のため、未配信・取得失敗時は +// 既存挙動(利用可能)を維持する true をフォールバックとする。 +const TTS_ENABLED_FALLBACK = true; + // リモート設定の数値は「有限かつ正」のみ受理する(0・負値・非数はフォールバックへ倒す)。 // 真偽値や配列は Number() で 1 や 5 に化けるため、number 型に限定してから検証する。 const parsePositiveFiniteNumber = (value: unknown): number | null => { @@ -56,6 +62,26 @@ let cachedForceNotArrivedEnabled: boolean | null = null; let cachedEtaAssistEnabled: boolean | null = null; let cachedEtaFallbackArrivalConfirmMarginSec: number | null = null; let cachedEtaFallbackMaxDurationMin: number | null = null; +let cachedTTSEnabled: boolean | null = null; + +// setupRemoteConfig は起動時に非同期で完了するため、初回レンダー後にキャッシュが +// 更新されても React は再レンダーしない。UI(FxTTS・設定画面)が useSyncExternalStore +// 経由でキャッシュ更新へ追従できるよう、変更通知のリスナーを提供する。 +const remoteConfigListeners = new Set<() => void>(); + +// キャッシュ更新をUIへ購読させる。戻り値は購読解除関数(useSyncExternalStore 互換)。 +export const subscribeRemoteConfig = (listener: () => void): (() => void) => { + remoteConfigListeners.add(listener); + return () => { + remoteConfigListeners.delete(listener); + }; +}; + +const notifyRemoteConfigListeners = (): void => { + for (const listener of remoteConfigListeners) { + listener(); + } +}; // テスト用および値の再取得時にキャッシュを破棄する。 export const resetRemoteConfigCache = (): void => { @@ -64,6 +90,8 @@ export const resetRemoteConfigCache = (): void => { cachedEtaAssistEnabled = null; cachedEtaFallbackArrivalConfirmMarginSec = null; cachedEtaFallbackMaxDurationMin = null; + cachedTTSEnabled = null; + notifyRemoteConfigListeners(); }; // 起動時に一度だけ Worker からリモート設定を取得しキャッシュへ格納する。 @@ -99,6 +127,10 @@ export const setupRemoteConfig = async (): Promise => { if (maxDurationMin != null) { cachedEtaFallbackMaxDurationMin = maxDurationMin; } + if (typeof data.tts_enabled === 'boolean') { + cachedTTSEnabled = data.tts_enabled; + } + notifyRemoteConfigListeners(); }; // 最大許容精度(m)を同期的に取得する。setupRemoteConfig 完了後は取得済みの @@ -121,23 +153,18 @@ export const isForceNotArrivedOnLowAccuracyEnabled = (): boolean => { return FORCE_NOT_ARRIVED_ON_LOW_ACCURACY_FALLBACK; }; -// Remote Config が配信する ETA補助の許可(マスタースイッチ)を同期的に取得する。 -// setupRemoteConfig 完了後は取得済みのリモート値を、未設定・取得失敗時はフォールバック -// (false=既定無効)を返す。これが false のときは機能を提供せず、設定画面の手動トグルも -// 操作不可にする(ExperimentalSettings 側で disabled)。 -export const isEtaAssistRemoteEnabled = (): boolean => { +// ETA補助機能の実効的な有効/無効を同期的に取得する。以前は設定画面の手動トグルとの AND で +// 判定していたが、手動トグルを廃止し自動有効化に変更したため、Remote Config が配信する +// マスタースイッチ(eta_assist_enabled)のみで判定する。setupRemoteConfig 完了後は取得済みの +// リモート値を、未設定・取得失敗時はフォールバック(false=既定無効)を返す。これが false の +// ときは機能を提供しない(サーバー側キルスイッチ)。 +export const isEtaAssistEnabled = (): boolean => { if (cachedEtaAssistEnabled != null) { return cachedEtaAssistEnabled; } return ETA_ASSIST_ENABLED_FALLBACK; }; -// ETA補助機能の実効的な有効/無効を同期的に取得する。Remote Config が許可(マスターON)し、 -// かつ設定画面の手動トグルもONのときだけ有効。Remote が false のときは手動トグルの値に -// 関わらず無効。 -export const isEtaAssistEnabled = (): boolean => - isEtaAssistRemoteEnabled() && store.get(etaAssistManualEnabledAtom); - // ETAフォールバックの到着確定マージン(秒)を同期的に取得する。setupRemoteConfig 完了後は // 取得済みのリモート値を、未完了・未設定・取得失敗時はフォールバックを返す。 // 0 や負値・非数といった不正値もフォールバックへ倒す。 @@ -157,3 +184,13 @@ export const getEtaFallbackMaxDurationMin = (): number => { } return ETA_FALLBACK_MAX_DURATION_MIN_FALLBACK; }; + +// TTS(自動アナウンス)機能の有効/無効を同期的に取得する。setupRemoteConfig 完了後は +// 取得済みのリモート値を、未設定・取得失敗時はフォールバック(true=利用可能)を返す。 +// false のとき設定画面のTTSトグルは無効化される(サーバー側キルスイッチ)。 +export const isTTSFeatureEnabled = (): boolean => { + if (cachedTTSEnabled != null) { + return cachedTTSEnabled; + } + return TTS_ENABLED_FALLBACK; +}; diff --git a/src/providers/DeepLinkProvider.tsx b/src/providers/DeepLinkProvider.tsx index b30c3b5daf..bb95f6b28a 100644 --- a/src/providers/DeepLinkProvider.tsx +++ b/src/providers/DeepLinkProvider.tsx @@ -8,7 +8,7 @@ type Props = { }; const DeepLinkProvider = ({ children }: Props) => { - const { initialUrlProcessed, isLoading, error } = useDeepLink(); + const { initialUrlProcessed, error } = useDeepLink(); useEffect(() => { if (error) { console.error(error); @@ -19,7 +19,12 @@ const DeepLinkProvider = ({ children }: Props) => { ); } }, [error]); - if (!initialUrlProcessed || (isLoading && !error)) { + // ゲートは初期URLの処理完了までに限定する。initialUrlProcessed は + // handleUrl の await 完了後(=初期リンクのフェッチ解決後)に立つため、 + // 初回起動のちらつき防止はこれだけで足りる。isLoading を条件に含めると、 + // 稼働中に受けたランタイムディープリンクの解決中もツリー全体が null になり、 + // 表示中の画面が一瞬消えてナビゲーション状態も破棄されてしまう。 + if (!initialUrlProcessed) { return null; } diff --git a/src/screens/AndroidSettings.tsx b/src/screens/AndroidSettings.tsx index 1bdaaf3e19..b2ad293c81 100644 --- a/src/screens/AndroidSettings.tsx +++ b/src/screens/AndroidSettings.tsx @@ -3,14 +3,11 @@ import { useAtom, useAtomValue } from 'jotai'; import React, { useCallback, useRef, useState } from 'react'; import { Alert, - type NativeScrollEvent, - type NativeSyntheticEvent, Pressable, Animated as RNAnimated, StyleSheet, View, } from 'react-native'; -import Animated from 'react-native-reanimated'; import Button from '~/components/Button'; import FooterTabBar from '~/components/FooterTabBar'; import { SettingsHeader } from '~/components/SettingsHeader'; @@ -58,17 +55,16 @@ const AndroidSettingsScreen: React.FC = () => { } }, [pictureInPictureEnabled, setPictureInPicture]); - const handleScroll = useCallback( - (e: NativeSyntheticEvent) => { - scrollY.setValue(e.nativeEvent.contentOffset.y); - }, - [scrollY] - ); + const handleScroll = useRef( + RNAnimated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], { + useNativeDriver: true, + }) + ).current; return ( <> - { > OK - + { color: '#FF3B30', onPress: () => navigation.navigate('NotificationSettings' as never), }, + { + id: SETTING_ITEM_ID_MAP.personalize_battery, + title: translate('batterySettings'), + color: '#30B0C7', + onPress: () => navigation.navigate('BatterySettings' as never), + }, // 試験的機能はカナリアリリース(devアプリ)限定で表示する ...(isDevApp ? [ @@ -367,17 +373,16 @@ const AppSettingsScreen: React.FC = () => { [navigation] ); - const handleScroll = useCallback( - (e: NativeSyntheticEvent) => { - scrollY.setValue(e.nativeEvent.contentOffset.y); - }, - [scrollY] - ); + const handleScroll = useRef( + RNAnimated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], { + useNativeDriver: true, + }) + ).current; return ( <> - { {!isDevApp && isBetaBuild ? translate('betaNotice') : ''} ) : null} - + ({ + useNavigation: () => ({ + goBack: jest.fn(), + }), +})); + +jest.mock('~/components/FooterTabBar', () => () => null); +jest.mock('~/components/SettingsHeader', () => ({ + SettingsHeader: () => null, +})); +jest.mock('~/components/Button', () => () => null); +jest.mock('~/translation', () => ({ + translate: (key: string) => key, +})); + +const renderWithStore = (powerSavingLocationEnabled = false) => { + const store = createStore(); + store.set(powerSavingLocationEnabledAtom, powerSavingLocationEnabled); + + const screen = render( + + + + ); + + return { ...screen, store }; +}; + +describe('BatterySettingsScreen', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('省電力測位モードをONにするとatomとストレージへ保存される', () => { + const { getByLabelText, store } = renderWithStore(false); + + fireEvent.press(getByLabelText('powerSavingLocationTitle')); + + expect(store.get(powerSavingLocationEnabledAtom)).toBe(true); + expect(storage.getString(STORAGE_KEYS.POWER_SAVING_LOCATION_ENABLED)).toBe( + 'true' + ); + }); + + it('省電力測位モードをOFFにするとatomとストレージへ保存される', () => { + const { getByLabelText, store } = renderWithStore(true); + + fireEvent.press(getByLabelText('powerSavingLocationTitle')); + + expect(store.get(powerSavingLocationEnabledAtom)).toBe(false); + expect(storage.getString(STORAGE_KEYS.POWER_SAVING_LOCATION_ENABLED)).toBe( + 'false' + ); + }); + + it('ストレージへの保存に失敗した場合はatom状態をロールバックしエラーを通知する', () => { + const setSpy = jest.spyOn(storage, 'set').mockImplementationOnce(() => { + throw new Error('storage failure'); + }); + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + + const { getByLabelText, store } = renderWithStore(false); + + fireEvent.press(getByLabelText('powerSavingLocationTitle')); + + // 保存失敗後にロールバックされる(MMKVは同期APIのため即時) + expect(store.get(powerSavingLocationEnabledAtom)).toBe(false); + + // エラーログとユーザーへのアラート表示を検証 + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to save power saving location setting', + expect.any(Error) + ); + expect(alertSpy).toHaveBeenCalledWith( + 'errorTitle', + 'failedToSavePreference' + ); + + setSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + alertSpy.mockRestore(); + }); +}); diff --git a/src/screens/BatterySettings.tsx b/src/screens/BatterySettings.tsx new file mode 100644 index 0000000000..8b40590e7e --- /dev/null +++ b/src/screens/BatterySettings.tsx @@ -0,0 +1,151 @@ +import { useNavigation } from '@react-navigation/native'; +import { useAtom, useAtomValue } from 'jotai'; +import React, { useCallback, useRef, useState } from 'react'; +import { + Alert, + Pressable, + Animated as RNAnimated, + StyleSheet, + View, +} from 'react-native'; +import Button from '~/components/Button'; +import FooterTabBar from '~/components/FooterTabBar'; +import { SettingsHeader } from '~/components/SettingsHeader'; +import { StatePanel } from '~/components/ToggleButton'; +import Typography from '~/components/Typography'; +import { powerSavingLocationEnabledAtom } from '~/store/atoms/battery'; +import { isLEDThemeAtom } from '~/store/atoms/theme'; +import { translate } from '~/translation'; +import { STORAGE_KEYS } from '../constants'; +import { storage } from '../lib/storage'; + +const styles = StyleSheet.create({ + root: { + paddingHorizontal: 24, + flex: 1, + }, + screenBg: { + backgroundColor: '#FAFAFA', + }, + description: { + marginTop: 16, + color: '#8B8B8B', + lineHeight: 21, + }, + okButton: { + width: 128, + alignSelf: 'center', + marginTop: 32, + }, +}); + +const ToggleItem = ({ + title, + state, + onToggle, +}: { + title: string; + state: boolean; + onToggle: () => void; +}) => { + const isLEDTheme = useAtomValue(isLEDThemeAtom); + + return ( + + + {title} + + + + + ); +}; + +const BatterySettingsScreen: React.FC = () => { + const [headerHeight, setHeaderHeight] = useState(0); + + const scrollY = useRef(new RNAnimated.Value(0)).current; + + const isLEDTheme = useAtomValue(isLEDThemeAtom); + const [powerSavingLocationEnabled, setPowerSavingLocationEnabled] = useAtom( + powerSavingLocationEnabledAtom + ); + + const navigation = useNavigation(); + + const handleTogglePowerSavingLocation = useCallback(() => { + const flag = !powerSavingLocationEnabled; + setPowerSavingLocationEnabled(flag); + try { + storage.set( + STORAGE_KEYS.POWER_SAVING_LOCATION_ENABLED, + flag ? 'true' : 'false' + ); + } catch (error) { + // 保存に失敗したままだと次回起動時に設定が巻き戻るため、 + // UIと永続値の不整合を防ぐべくatom状態をロールバックする + setPowerSavingLocationEnabled(!flag); + console.error('Failed to save power saving location setting', error); + Alert.alert(translate('errorTitle'), translate('failedToSavePreference')); + } + }, [powerSavingLocationEnabled, setPowerSavingLocationEnabled]); + + const handleScroll = useRef( + RNAnimated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], { + useNativeDriver: true, + }) + ).current; + + return ( + <> + + + + + {translate('powerSavingLocationDescription')} + + + + + setHeaderHeight(e.nativeEvent.layout.height + 32)} + scrollY={scrollY} + /> + + + ); +}; + +export default React.memo(BatterySettingsScreen); diff --git a/src/screens/EnabledLanguagesSettings.tsx b/src/screens/EnabledLanguagesSettings.tsx index bfbbc12b12..3e20dc90d0 100644 --- a/src/screens/EnabledLanguagesSettings.tsx +++ b/src/screens/EnabledLanguagesSettings.tsx @@ -4,15 +4,12 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; import { Alert, type GestureResponderEvent, - type NativeScrollEvent, - type NativeSyntheticEvent, Platform, Pressable, Animated as RNAnimated, StyleSheet, View, } from 'react-native'; -import Animated from 'react-native-reanimated'; import Button from '~/components/Button'; import FooterTabBar from '~/components/FooterTabBar'; import { SettingsHeader } from '~/components/SettingsHeader'; @@ -103,6 +100,43 @@ const SettingsItem = ({ ); }; +const ListFooter = ({ onPressOK }: { onPressOK: () => void }) => ( + <> + + {translate('requireJapaneseOrEnglish')} + + + + + + {translate('ttsLanguageSettings')} + + + +); + const EnabledLanguagesSettings: React.FC = () => { const [headerHeight, setHeaderHeight] = useState(0); @@ -213,17 +247,16 @@ const EnabledLanguagesSettings: React.FC = () => { [] ); - const handleScroll = useCallback( - (e: NativeSyntheticEvent) => { - scrollY.setValue(e.nativeEvent.contentOffset.y); - }, - [scrollY] - ); + const handleScroll = useRef( + RNAnimated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], { + useNativeDriver: true, + }) + ).current; return ( <> - { ]} renderItem={renderItem} onScroll={handleScroll} - ListFooterComponent={() => ( - <> - - {translate('requireJapaneseOrEnglish')} - - - - - - {translate('ttsLanguageSettings')} - - - - )} + scrollEventThrottle={16} + ListFooterComponent={ + navigation.goBack()} /> + } /> ({ const renderWithStore = ( portraitModeEnabled: boolean, - telemetryEnabled = false, - etaAssistManualEnabled = false, - powerSavingLocationEnabled = false + telemetryEnabled = false ) => { const store = createStore(); store.set(portraitModeEnabledAtom, portraitModeEnabled); - store.set(etaAssistManualEnabledAtom, etaAssistManualEnabled); - store.set(powerSavingLocationEnabledAtom, powerSavingLocationEnabled); store.set(tuningState, (prev) => ({ ...prev, telemetryEnabled })); const screen = render( @@ -102,74 +93,10 @@ describe('ExperimentalSettingsScreen', () => { alertSpy.mockRestore(); }); - it('Remote Config が許可していればETA補助をONにできる(atomとストレージへ保存)', () => { - jest - .spyOn(remoteConfigModule, 'isEtaAssistRemoteEnabled') - .mockReturnValue(true); - const { getByLabelText, store } = renderWithStore(false); - - fireEvent.press(getByLabelText('etaAssistTitle')); - - expect(store.get(etaAssistManualEnabledAtom)).toBe(true); - expect(storage.getString(STORAGE_KEYS.ETA_ASSIST_MANUAL_ENABLED)).toBe( - 'true' - ); - }); - - it('Remote Config が許可していればETA補助をOFFにできる(atomとストレージへ保存)', () => { - jest - .spyOn(remoteConfigModule, 'isEtaAssistRemoteEnabled') - .mockReturnValue(true); - const { getByLabelText, store } = renderWithStore(false, false, true); - - fireEvent.press(getByLabelText('etaAssistTitle')); - - expect(store.get(etaAssistManualEnabledAtom)).toBe(false); - expect(storage.getString(STORAGE_KEYS.ETA_ASSIST_MANUAL_ENABLED)).toBe( - 'false' - ); - }); - - it('Remote Config が false のときはETA補助トグルを操作できない', () => { - jest - .spyOn(remoteConfigModule, 'isEtaAssistRemoteEnabled') - .mockReturnValue(false); - const { getByLabelText, store } = renderWithStore(false); - - // 操作不可(disabled)。押してもatomはONにならず、ONの永続化も走らない。 - fireEvent.press(getByLabelText('etaAssistTitle')); + it('ETA補助トグルは廃止され表示されない(自動有効化)', () => { + const { queryByLabelText } = renderWithStore(false); - expect(store.get(etaAssistManualEnabledAtom)).toBe(false); - expect(storage.getString(STORAGE_KEYS.ETA_ASSIST_MANUAL_ENABLED)).not.toBe( - 'true' - ); - }); - - it('省電力測位モードをONにするとatomとストレージへ保存される', () => { - const { getByLabelText, store } = renderWithStore(false); - - fireEvent.press(getByLabelText('powerSavingLocationTitle')); - - expect(store.get(powerSavingLocationEnabledAtom)).toBe(true); - expect(storage.getString(STORAGE_KEYS.POWER_SAVING_LOCATION_ENABLED)).toBe( - 'true' - ); - }); - - it('省電力測位モードをOFFにするとatomとストレージへ保存される', () => { - const { getByLabelText, store } = renderWithStore( - false, - false, - false, - true - ); - - fireEvent.press(getByLabelText('powerSavingLocationTitle')); - - expect(store.get(powerSavingLocationEnabledAtom)).toBe(false); - expect(storage.getString(STORAGE_KEYS.POWER_SAVING_LOCATION_ENABLED)).toBe( - 'false' - ); + expect(queryByLabelText('etaAssistTitle')).toBeNull(); }); it('テレメトリをONにするとatomとストレージへ保存される', () => { diff --git a/src/screens/ExperimentalSettings.tsx b/src/screens/ExperimentalSettings.tsx index e2e9ef48db..4ea113d567 100644 --- a/src/screens/ExperimentalSettings.tsx +++ b/src/screens/ExperimentalSettings.tsx @@ -3,11 +3,8 @@ import { useAtom, useAtomValue } from 'jotai'; import React, { useCallback, useRef, useState } from 'react'; import { Alert, - type NativeScrollEvent, - type NativeSyntheticEvent, Pressable, Animated as RNAnimated, - ScrollView, StyleSheet, View, } from 'react-native'; @@ -16,12 +13,7 @@ import FooterTabBar from '~/components/FooterTabBar'; import { SettingsHeader } from '~/components/SettingsHeader'; import { StatePanel } from '~/components/ToggleButton'; import Typography from '~/components/Typography'; -import { isEtaAssistRemoteEnabled } from '~/lib/remoteConfig'; -import { - etaAssistManualEnabledAtom, - portraitModeEnabledAtom, - powerSavingLocationEnabledAtom, -} from '~/store/atoms/experimental'; +import { portraitModeEnabledAtom } from '~/store/atoms/experimental'; import { isLEDThemeAtom } from '~/store/atoms/theme'; import tuningState from '~/store/atoms/tuning'; import { translate } from '~/translation'; @@ -105,18 +97,8 @@ const ExperimentalSettingsScreen: React.FC = () => { const [portraitModeEnabled, setPortraitModeEnabled] = useAtom( portraitModeEnabledAtom ); - const [etaAssistManualEnabled, setEtaAssistManualEnabled] = useAtom( - etaAssistManualEnabledAtom - ); - const [powerSavingLocationEnabled, setPowerSavingLocationEnabled] = useAtom( - powerSavingLocationEnabledAtom - ); const [tuning, setTuning] = useAtom(tuningState); - // Remote Config(マスタースイッチ)が許可していない間は、手動トグルを操作不可にする。 - // 配信状態は起動時に確定する非リアクティブ値のため、レンダー時に一度参照すれば足りる。 - const etaAssistRemoteEnabled = isEtaAssistRemoteEnabled(); - const navigation = useNavigation(); const handleTogglePortraitMode = useCallback(() => { @@ -133,48 +115,6 @@ const ExperimentalSettingsScreen: React.FC = () => { } }, [portraitModeEnabled, setPortraitModeEnabled]); - const handleToggleEtaAssist = useCallback(() => { - // Remote Config が許可していないときは操作させない(UI側でも disabled)。 - if (!etaAssistRemoteEnabled) { - return; - } - const flag = !etaAssistManualEnabled; - setEtaAssistManualEnabled(flag); - try { - storage.set( - STORAGE_KEYS.ETA_ASSIST_MANUAL_ENABLED, - flag ? 'true' : 'false' - ); - } catch (error) { - // 保存に失敗したままだと次回起動時に設定が巻き戻るため、 - // UIと永続値の不整合を防ぐべくatom状態をロールバックする - setEtaAssistManualEnabled(!flag); - console.error('Failed to save ETA assist setting', error); - Alert.alert(translate('errorTitle'), translate('failedToSavePreference')); - } - }, [ - etaAssistManualEnabled, - setEtaAssistManualEnabled, - etaAssistRemoteEnabled, - ]); - - const handleTogglePowerSavingLocation = useCallback(() => { - const flag = !powerSavingLocationEnabled; - setPowerSavingLocationEnabled(flag); - try { - storage.set( - STORAGE_KEYS.POWER_SAVING_LOCATION_ENABLED, - flag ? 'true' : 'false' - ); - } catch (error) { - // 保存に失敗したままだと次回起動時に設定が巻き戻るため、 - // UIと永続値の不整合を防ぐべくatom状態をロールバックする - setPowerSavingLocationEnabled(!flag); - console.error('Failed to save power saving location setting', error); - Alert.alert(translate('errorTitle'), translate('failedToSavePreference')); - } - }, [powerSavingLocationEnabled, setPowerSavingLocationEnabled]); - const handleToggleTelemetry = useCallback(() => { const flag = !tuning.telemetryEnabled; setTuning((prev) => ({ ...prev, telemetryEnabled: flag })); @@ -187,17 +127,16 @@ const ExperimentalSettingsScreen: React.FC = () => { } }, [tuning.telemetryEnabled, setTuning]); - const handleScroll = useCallback( - (e: NativeSyntheticEvent) => { - scrollY.setValue(e.nativeEvent.contentOffset.y); - }, - [scrollY] - ); + const handleScroll = useRef( + RNAnimated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], { + useNativeDriver: true, + }) + ).current; return ( <> - { {translate('telemetryDescription')} - - - - - {translate('etaAssistDescription')} - - - - - - {translate('powerSavingLocationDescription')} - {translate('experimentalSettingsNotice')} @@ -255,7 +173,7 @@ const ExperimentalSettingsScreen: React.FC = () => { > OK - + void; +}) => ( + <> + + {translate('odptDisclaimer')} + + + +); + const CC_BY_URL = 'https://creativecommons.org/licenses/by/4.0/'; const APACHE_2_URL = 'https://www.apache.org/licenses/LICENSE-2.0'; const MIT_URL = 'https://opensource.org/licenses/MIT'; @@ -316,17 +339,16 @@ const Licenses: React.FC = () => { [] ); - const handleScroll = useCallback( - (e: NativeSyntheticEvent) => { - scrollY.setValue(e.nativeEvent.contentOffset.y); - }, - [scrollY] - ); + const handleScroll = useRef( + RNAnimated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], { + useNativeDriver: true, + }) + ).current; return ( <> - { ]} renderItem={renderItem} onScroll={handleScroll} - ListFooterComponent={() => ( - <> - - {translate('odptDisclaimer')} - - - - )} + scrollEventThrottle={16} + ListFooterComponent={ + navigation.goBack()} + /> + } /> { + useSimulationMode(); + return null; +}; +const FxFirstStop: React.FC = () => { + useFirstStop(true); + return null; +}; +const FxTelemetrySender: React.FC = () => { + useTelemetrySender(true); + return null; +}; +const FxConsoleTelemetry: React.FC = () => { + useConsoleTelemetry(); + return null; +}; +const FxTransitionHeaderState: React.FC = () => { + useTransitionHeaderState(); + return null; +}; +const FxRefreshLeftStations: React.FC = () => { + useRefreshLeftStations(); + return null; +}; +const FxRefreshStation: React.FC = () => { + useRefreshStation(); + return null; +}; +const FxEtaAnchor: React.FC = () => { + useEtaAnchor(); + return null; +}; +const FxEtaFallback: React.FC = () => { + useEtaFallback(); + return null; +}; +const FxKeepAwake: React.FC = () => { + useKeepAwake(); + return null; +}; +const FxStartBackgroundLocationUpdates: React.FC = () => { + useStartBackgroundLocationUpdates(); + return null; +}; +const FxUpdateLiveActivitiesInner: React.FC = () => { + useUpdateLiveActivities(); + return null; +}; +// LiveActivities は iOS 専用。Android では activityState の派生計算自体を止める。 +const FxUpdateLiveActivities: React.FC = () => { + return Platform.OS === 'ios' ? : null; +}; +const FxAndroidPictureInPicture: React.FC = () => { + useAndroidPictureInPicture(); + return null; +}; + +const MainScreenEffects: React.FC = () => { + return ( + <> + + + + + + + + + + + + + + {Platform.OS === 'android' && } + + ); +}; + const MainScreen: React.FC = () => { const [isSelectBoundModalOpen, setIsSelectBoundModalOpen] = useState(false); const theme = useAtomValue(themeAtom); const isLEDTheme = useAtomValue(isLEDThemeAtom); - const { active: pictureInPictureActive } = useAtomValue(pictureInPictureAtom); + const pictureInPictureActive = useAtomValue(pictureInPictureActiveAtom); const stations = useAtomValue(stationsAtom); const selectedDirection = useAtomValue(selectedDirectionAtom); @@ -132,8 +215,8 @@ const MainScreen: React.FC = () => { const bottomState = useAtomValue(bottomStateAtom); const setNavigationState = useSetAtom(navigationState); const setLineState = useSetAtom(lineState); - const { devOverlayEnabled } = useAtomValue(tuningState); - const { untouchableModeEnabled } = useAtomValue(tuningState); + const { devOverlayEnabled, untouchableModeEnabled } = + useAtomValue(tuningState); const portraitModeEnabled = useAtomValue(portraitModeEnabledAtom); const currentLine = useCurrentLine(); @@ -159,12 +242,6 @@ const MainScreen: React.FC = () => { }; }, [windowWidth, windowHeight]); - useSimulationMode(); - useFirstStop(true); - - useTelemetrySender(true); - useConsoleTelemetry(); - const { isYamanoteLine, isOsakaLoopLine, isMeijoLine } = useLoopLine(); const [ @@ -260,17 +337,7 @@ const MainScreen: React.FC = () => { trainType, ]); - useTransitionHeaderState(); - useRefreshLeftStations(); - useRefreshStation(); - useEtaAnchor(); - useEtaFallback(); - useKeepAwake(); - useStartBackgroundLocationUpdates(); const resetMainState = useResetMainState(); - useTTS(); - useUpdateLiveActivities(); - useAndroidPictureInPicture(); const { pause: pauseBottomTimer } = useUpdateBottomState(); @@ -675,7 +742,12 @@ const MainScreen: React.FC = () => { }, [bottomState, handleTransferPress, hasTerminus, theme, transferStation]); if (pictureInPictureActive) { - return ; + return ( + <> + + + + ); } // ポートレートモード有効時、端末が縦向きの間はテーマ非依存の @@ -683,6 +755,7 @@ const MainScreen: React.FC = () => { if (portraitModeEnabled && windowHeight > windowWidth) { return ( <> + {isDevApp && devOverlayEnabled && } @@ -691,15 +764,19 @@ const MainScreen: React.FC = () => { if (isLEDTheme) { return ( - -
- - + <> + + +
+ + + ); } return ( <> + { } }, [wrongDirectionNotifyEnabled, setNotifyState]); - const handleScroll = useCallback( - (e: NativeSyntheticEvent) => { - scrollY.setValue(e.nativeEvent.contentOffset.y); - }, - [scrollY] - ); + const handleScroll = useRef( + RNAnimated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], { + useNativeDriver: true, + }) + ).current; return ( <> - { > OK - + > +); + const RouteSearchScreen = () => { const [nowHeaderHeight, setNowHeaderHeight] = useState(0); const [selectBoundModalVisible, setSelectBoundModalVisible] = useState(false); @@ -429,55 +442,32 @@ const RouteSearchScreen = () => { [handleLineSelected, fetchRouteTypesLoading] ); - const renderPlaceholders = useCallback((rowIndex: number, count: number) => { - if (!isTablet || count <= 0) { - return null; - } - - return Array.from({ length: count }).map((_, i) => ( - - )); - }, []); + const renderItem = ({ item, index }: ListRenderItemInfo) => { + const columnIndex = index % numColumns; + + return ( + = numColumns && styles.rowSpacing, + // タブレットではセル幅が listWidth / numColumns 固定になるため、 + // 各セルの左右 padding を列位置に応じて振り分けることで + // 従来の gap: 16 と同一のカード幅・カード間隔を再現する + isTablet && { + paddingLeft: (CARD_COLUMN_GAP * columnIndex) / numColumns, + paddingRight: + (CARD_COLUMN_GAP * (numColumns - 1 - columnIndex)) / numColumns, + }, + ]} + > + {renderCard(item)} + + ); + }; - const renderStationRow = useCallback( - (rowStations: Station[], rowIndex: number) => { - return ( - <> - {rowIndex > 0 && } - - {rowStations.map((item, colIndex) => { - return ( - - {renderCard(item)} - - ); - })} - {renderPlaceholders(rowIndex, numColumns - rowStations.length)} - - - ); - }, - [numColumns, renderCard, renderPlaceholders] - ); + const keyExtractor = (s: Station, index: number) => + `${s.groupId ?? 0}-${s.id ?? index}`; const handleTrainTypeSelected = useCallback( async (trainType: TrainType) => { @@ -516,11 +506,11 @@ const RouteSearchScreen = () => { ] ); - const handleScroll = useCallback( - (e: NativeSyntheticEvent) => { - scrollY.setValue(e.nativeEvent.contentOffset.y); - }, - [scrollY] + // NowHeader のスクロール連動アニメーションを native driver で駆動する + // (AnimatedFlashList にアタッチされ、スクロールイベントは UI スレッドで scrollY へ反映される) + const handleScroll = RNAnimated.event( + [{ nativeEvent: { contentOffset: { y: scrollY } } }], + { useNativeDriver: true } ); const currentStationInRoutes = useMemo( @@ -618,54 +608,42 @@ const RouteSearchScreen = () => { return ( <> - - - - + ListHeaderComponent={ + + + + + + {translate('searchResult')} + - - {translate('searchResult')} - - - - - {!searchResults.length ? ( + } + ListEmptyComponent={ + - ) : ( - Array.from({ - length: Math.ceil(searchResults.length / numColumns), - }).map((_, rowIndex) => { - const rowStations = searchResults.slice( - rowIndex * numColumns, - (rowIndex + 1) * numColumns - ); - const rowKey = rowStations.map((s) => s.id).join('-'); - return ( - - {renderStationRow(rowStations, rowIndex)} - - ); - }) - )} - - - - + + } + ListFooterComponent={EmptyLineSeparator} + /> { ); // --- スクロールハンドラ --- - const handleScroll = useCallback( - (e: NativeSyntheticEvent) => { - scrollY.setValue(e.nativeEvent.contentOffset.y); - }, - [scrollY] - ); + const handleScroll = useRef( + RNAnimated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], { + useNativeDriver: true, + }) + ).current; const handleRefresh = useCallback(async () => { setRefreshing(true); @@ -304,7 +301,7 @@ const SelectLineScreen = () => { return ( <> - { )} - + {/* 固定ヘッダー */} diff --git a/src/screens/TTSSettings.test.tsx b/src/screens/TTSSettings.test.tsx index 3f9851ff8e..cdc6abf838 100644 --- a/src/screens/TTSSettings.test.tsx +++ b/src/screens/TTSSettings.test.tsx @@ -1,6 +1,8 @@ -import { fireEvent, render } from '@testing-library/react-native'; +import { fireEvent, render, waitFor } from '@testing-library/react-native'; import { createStore, Provider } from 'jotai'; -import { STORAGE_KEYS } from '~/constants'; +import { Alert, Linking } from 'react-native'; +import { STATUS_URL, STORAGE_KEYS } from '~/constants'; +import { isTTSFeatureEnabled } from '~/lib/remoteConfig'; import { storage } from '~/lib/storage'; import speechState, { type StationState } from '~/store/atoms/speech'; import TTSSettingsScreen from './TTSSettings'; @@ -9,6 +11,13 @@ jest.mock('~/utils/isDevApp', () => ({ isDevApp: false, })); +jest.mock('~/lib/remoteConfig', () => ({ + isTTSFeatureEnabled: jest.fn(() => true), + subscribeRemoteConfig: jest.fn(() => () => {}), +})); + +const mockedIsTTSFeatureEnabled = jest.mocked(isTTSFeatureEnabled); + jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ goBack: jest.fn(), @@ -48,6 +57,10 @@ const renderWithSpeechState = (speech: Partial) => { }; describe('TTSSettingsScreen', () => { + beforeEach(() => { + mockedIsTTSFeatureEnabled.mockReturnValue(true); + }); + afterEach(() => { jest.clearAllMocks(); }); @@ -89,4 +102,81 @@ describe('TTSSettingsScreen', () => { expect(store.get(speechState).ttsEnabledLanguages).toEqual(['JA', 'EN']); expect(storage.contains(STORAGE_KEYS.TTS_ENABLED_LANGUAGES)).toBe(false); }); + + describe('feature flag(tts_enabled)がfalseの場合', () => { + beforeEach(() => { + mockedIsTTSFeatureEnabled.mockReturnValue(false); + }); + + it('TTSトグルをOFF表示かつ無効化し設定を変更できない', () => { + const { getByLabelText, store } = renderWithSpeechState({ + enabled: true, + }); + + const ttsToggle = getByLabelText('toEnabled'); + expect(ttsToggle.props.accessibilityState).toMatchObject({ + checked: false, + disabled: true, + }); + + fireEvent.press(ttsToggle); + + // 保存済みのユーザー設定自体は破棄しない(フラグ復帰時に元へ戻る) + expect(store.get(speechState).enabled).toBe(true); + expect(storage.contains(STORAGE_KEYS.SPEECH_ENABLED)).toBe(false); + }); + + it('バックグラウンド再生・言語トグルも無効化される', () => { + const { getByLabelText } = renderWithSpeechState({ + enabled: true, + backgroundEnabled: true, + }); + + expect( + getByLabelText('autoAnnounceBackgroundTitle').props.accessibilityState + ).toMatchObject({ checked: false, disabled: true }); + expect(getByLabelText('japanese').props.accessibilityState).toMatchObject( + { disabled: true } + ); + expect(getByLabelText('english').props.accessibilityState).toMatchObject({ + disabled: true, + }); + }); + + it('利用不可の説明とサービスステータスリンクを表示しタップでブラウザを開く', () => { + const openURLSpy = jest.spyOn(Linking, 'openURL').mockResolvedValue(true); + + const { getByText } = renderWithSpeechState({ enabled: true }); + + expect(getByText('ttsFeatureDisabledText')).toBeTruthy(); + + fireEvent.press(getByText('serviceStatus')); + + expect(openURLSpy).toHaveBeenCalledWith(STATUS_URL); + }); + + it('サービスステータスリンクを開けなかった場合はエラーAlertを表示する', async () => { + jest + .spyOn(Linking, 'openURL') + .mockRejectedValue(new Error('cannot open')); + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const { getByText } = renderWithSpeechState({ enabled: true }); + + fireEvent.press(getByText('serviceStatus')); + + await waitFor(() => { + expect(alertSpy).toHaveBeenCalledWith('errorTitle', 'failedToOpenLink'); + }); + expect(errorSpy).toHaveBeenCalled(); + }); + }); + + it('feature flagがtrueの場合はサービスステータスリンクを表示しない', () => { + const { queryByText } = renderWithSpeechState({ enabled: true }); + + expect(queryByText('ttsFeatureDisabledText')).toBeNull(); + expect(queryByText('serviceStatus')).toBeNull(); + }); }); diff --git a/src/screens/TTSSettings.tsx b/src/screens/TTSSettings.tsx index 75a1f18742..680bee4c9b 100644 --- a/src/screens/TTSSettings.tsx +++ b/src/screens/TTSSettings.tsx @@ -4,24 +4,23 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; import { Alert, type GestureResponderEvent, - type NativeScrollEvent, - type NativeSyntheticEvent, + Linking, Pressable, Animated as RNAnimated, StyleSheet, View, } from 'react-native'; import { isClip } from 'react-native-app-clip'; -import Animated from 'react-native-reanimated'; import Button from '~/components/Button'; import FooterTabBar from '~/components/FooterTabBar'; import { SettingsHeader } from '~/components/SettingsHeader'; import { StatePanel } from '~/components/ToggleButton'; import Typography from '~/components/Typography'; +import { useTTSFeatureEnabled } from '~/hooks/useTTSFeatureEnabled'; import speechState from '~/store/atoms/speech'; import { isLEDThemeAtom } from '~/store/atoms/theme'; import { translate } from '~/translation'; -import { STORAGE_KEYS } from '../constants'; +import { STATUS_URL, STORAGE_KEYS } from '../constants'; import { storage } from '../lib/storage'; type SettingItem = { @@ -99,6 +98,89 @@ const SettingsItem = ({ ); }; +const ListFooter = ({ + ttsLanguageItems, + ttsEnabledLanguages, + speechEnabled, + ttsFeatureEnabled, + onToggleTTSLanguage, + onPressServiceStatus, + onPressOK, +}: { + ttsLanguageItems: TTSLanguageSettingItem[]; + ttsEnabledLanguages: TTSLanguage[]; + speechEnabled: boolean; + ttsFeatureEnabled: boolean; + onToggleTTSLanguage: (language: TTSLanguage) => void; + onPressServiceStatus: () => void; + onPressOK: () => void; +}) => ( + <> + + {ttsLanguageItems.map((item, index) => { + const state = ttsEnabledLanguages.includes(item.id); + const disabled = + !speechEnabled || + (item.id === 'JA' && state && !ttsEnabledLanguages.includes('EN')) || + (item.id === 'EN' && state && !ttsEnabledLanguages.includes('JA')); + + return ( + onToggleTTSLanguage(item.id)} + state={state} + disabled={disabled} + /> + ); + })} + + + {translate('requireJapaneseOrEnglish')} + + {!ttsFeatureEnabled ? ( + <> + + {translate('ttsFeatureDisabledText')} + + + {translate('serviceStatus')} + + + ) : null} + + +); + const TTSSettingsScreen: React.FC = () => { const [headerHeight, setHeaderHeight] = useState(0); @@ -112,6 +194,10 @@ const TTSSettingsScreen: React.FC = () => { const navigation = useNavigation(); + // Remote Config のキルスイッチ。起動時の非同期取得完了後に値が届いた場合も + // 購読経由で再レンダーされ、トグルの無効化が確実に反映される。 + const ttsFeatureEnabled = useTTSFeatureEnabled(); + const SETTING_ITEMS: SettingItem[] = [ { id: 'enable_tts', @@ -141,6 +227,10 @@ const TTSSettingsScreen: React.FC = () => { const handleToggleTTS = useCallback( (flag: boolean) => { + if (!ttsFeatureEnabled) { + return; + } + try { if (flag && !storage.contains(STORAGE_KEYS.TTS_NOTICE)) { Alert.alert(translate('notice'), translate('ttsAlertText'), [ @@ -178,7 +268,7 @@ const TTSSettingsScreen: React.FC = () => { ); } }, - [setSpeechState] + [setSpeechState, ttsFeatureEnabled] ); const handleToggleBgTTS = useCallback( @@ -271,14 +361,28 @@ const TTSSettingsScreen: React.FC = () => { [setSpeechState, ttsEnabledLanguages] ); + // キルスイッチOFF時は保存済みのユーザー設定を保持したまま、表示上はOFF・操作不可にする。 + const effectiveSpeechEnabled = speechEnabled && ttsFeatureEnabled; + const renderItem = useCallback( ({ item, index }: { item: SettingItem; index: number }) => { const state = (() => { switch (item.id) { case 'enable_tts': - return speechEnabled; + return effectiveSpeechEnabled; case 'enable_bg_tts': - return backgroundEnabled; + return effectiveSpeechEnabled ? backgroundEnabled : false; + default: + return false; + } + })(); + + const disabled = (() => { + switch (item.id) { + case 'enable_tts': + return !ttsFeatureEnabled; + case 'enable_bg_tts': + return !effectiveSpeechEnabled; default: return false; } @@ -301,8 +405,8 @@ const TTSSettingsScreen: React.FC = () => { isFirst={index === 0} isLast={index === SETTING_ITEMS.length - 1} onToggle={onToggle} - state={item.id === 'enable_bg_tts' && !speechEnabled ? false : state} - disabled={item.id === 'enable_bg_tts' && !speechEnabled} + state={state} + disabled={disabled} /> ); }, @@ -310,22 +414,30 @@ const TTSSettingsScreen: React.FC = () => { handleToggleTTS, handleToggleBgTTS, speechEnabled, + effectiveSpeechEnabled, backgroundEnabled, + ttsFeatureEnabled, SETTING_ITEMS.length, ] ); - const handleScroll = useCallback( - (e: NativeSyntheticEvent) => { - scrollY.setValue(e.nativeEvent.contentOffset.y); - }, - [scrollY] - ); + const handleServiceStatusPress = useCallback(() => { + Linking.openURL(STATUS_URL).catch((error) => { + console.error('Failed to open service status page', error); + Alert.alert(translate('errorTitle'), translate('failedToOpenLink')); + }); + }, []); + + const handleScroll = useRef( + RNAnimated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], { + useNativeDriver: true, + }) + ).current; return ( <> - item.id} contentContainerStyle={[ @@ -335,51 +447,18 @@ const TTSSettingsScreen: React.FC = () => { ]} renderItem={renderItem} onScroll={handleScroll} - ListFooterComponent={() => ( - <> - - {TTS_LANGUAGE_ITEMS.map((item, index) => { - const state = ttsEnabledLanguages.includes(item.id); - const disabled = - !speechEnabled || - (item.id === 'JA' && - state && - !ttsEnabledLanguages.includes('EN')) || - (item.id === 'EN' && - state && - !ttsEnabledLanguages.includes('JA')); - - return ( - handleToggleTTSLanguage(item.id)} - state={state} - disabled={disabled} - /> - ); - })} - - - {translate('requireJapaneseOrEnglish')} - - - - )} + scrollEventThrottle={16} + ListFooterComponent={ + navigation.goBack()} + /> + } /> void }) => ( + +); + const ThemeSettingsScreen: React.FC = () => { const [headerHeight, setHeaderHeight] = useState(0); const [pendingTheme, setPendingTheme] = useState(null); @@ -221,17 +228,16 @@ const ThemeSettingsScreen: React.FC = () => { [] ); - const handleScroll = useCallback( - (e: NativeSyntheticEvent) => { - scrollY.setValue(e.nativeEvent.contentOffset.y); - }, - [scrollY] - ); + const handleScroll = useRef( + RNAnimated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], { + useNativeDriver: true, + }) + ).current; return ( <> - { ]} renderItem={renderItem} onScroll={handleScroll} - ListFooterComponent={() => ( - - )} + scrollEventThrottle={16} + ListFooterComponent={ + navigation.goBack()} /> + } /> { name="AndroidSettings" component={AndroidSettings} /> + ({ active: false, activityState: null, }); + +// activityState は位置更新のたびに書き換わる(progress を含む)ため、 +// enabled / active だけが必要な購読者が pictureInPictureAtom を丸ごと購読すると +// 毎ティック再レンダーされてしまう。boolean の派生 atom は値が変わらない限り +// 通知されないので、こちらを購読する。 +export const pictureInPictureEnabledAtom = atom( + (get) => get(pictureInPictureAtom).enabled +); +export const pictureInPictureActiveAtom = atom( + (get) => get(pictureInPictureAtom).active +); diff --git a/src/utils/test/telemetrySenderTestSetup.tsx b/src/utils/test/telemetrySenderTestSetup.tsx new file mode 100644 index 0000000000..1e48b3fc31 --- /dev/null +++ b/src/utils/test/telemetrySenderTestSetup.tsx @@ -0,0 +1,140 @@ +import { Provider, useAtomValue } from 'jotai'; +import { useCurrentLine } from '~/hooks/useCurrentLine'; +import { useCurrentStation } from '~/hooks/useCurrentStation'; +import { useIsPassing } from '~/hooks/useIsPassing'; +import { useTelemetryEnabled } from '~/hooks/useTelemetryEnabled'; +import stationState from '~/store/atoms/station'; + +// useTelemetrySenderのテスト用共通セットアップ。 +// jest.mockはbabel-preset-jestによりこのモジュールの先頭へホイストされるため、 +// テストファイルが本モジュールをimportした時点で依存モジュールのモックが登録される。 +// テスト対象(useTelemetrySender)やモック済みフックは順序事故を防ぐため +// 必ず本モジュールのre-export経由でimportすること。 +jest.mock('expo-application', () => ({ + nativeApplicationVersion: '1.0.0', + nativeBuildVersion: '42', +})); +jest.mock('expo-crypto', () => ({ + randomUUID: jest.fn(() => 'test-session-id'), +})); +jest.mock('expo-device', () => ({ modelName: 'MockDevice' })); +jest.mock('~/utils/isDevApp', () => ({ isDevApp: false })); +jest.mock('expo-battery', () => ({ + BatteryState: { + UNKNOWN: 0, + UNPLUGGED: 1, + CHARGING: 2, + FULL: 3, + }, + getBatteryLevelAsync: jest.fn(), + getBatteryStateAsync: jest.fn(), +})); +jest.mock('expo-network', () => ({ + useNetworkState: jest.fn().mockReturnValue({ type: 'WIFI' }), + NetworkStateType: { WIFI: 'WIFI' }, +})); +jest.mock('~/utils/telemetryConfig', () => ({ + isTelemetryEnabledByBuild: true, +})); +jest.mock('jotai', () => { + const actual = jest.requireActual('jotai'); + return { + ...actual, + useAtomValue: jest.fn(), + }; +}); +jest.mock('~/hooks/useCurrentLine', () => ({ + useCurrentLine: jest.fn(), +})); +jest.mock('~/hooks/useCurrentStation', () => ({ + useCurrentStation: jest.fn(), +})); +jest.mock('~/hooks/useIsPassing', () => ({ + useIsPassing: jest.fn(), +})); +jest.mock('~/hooks/useTelemetryEnabled', () => ({ + useTelemetryEnabled: jest.fn(), +})); + +export { useTelemetrySender } from '~/hooks/useTelemetrySender'; +export { useTelemetryEnabled }; + +export const TELEMETRY_TEST_BASE_URL = 'https://example.com'; + +export const TelemetryTestWrapper = ({ + children, +}: { + children: React.ReactNode; +}) => ( + + {children} + +); + +// beforeEachから呼び、モックの既定値とfetchモックをまとめて設定する +export const setupTelemetrySenderMocks = (): jest.Mock => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: () => + Promise.resolve({ + data: { sendInteractionEvent: { sessionId: 'test-session-id' } }, + }), + }); + global.fetch = mockFetch; + + (useAtomValue as jest.Mock).mockReturnValue({ + coords: { + latitude: 35.0, + longitude: 139.0, + accuracy: 5, + speed: 10, + altitude: null, + altitudeAccuracy: null, + heading: null, + }, + timestamp: Date.now(), + }); + (useCurrentLine as jest.Mock).mockReturnValue({ id: 11302 }); + (useCurrentStation as jest.Mock).mockReturnValue({ id: 1130224 }); + (useIsPassing as jest.Mock).mockReturnValue(false); + (useTelemetryEnabled as jest.Mock).mockReturnValue(true); + + return mockFetch; +}; + +// app_launchの自動送信と手動送信イベントが混ざるため、eventNameで見分ける +export const findInteractionEventCalls = ( + mockFetch: jest.Mock, + eventName: string +) => + mockFetch.mock.calls.filter((call) => { + if (call[0] !== `${TELEMETRY_TEST_BASE_URL}/graphql`) { + return false; + } + const body = JSON.parse(call[1].body); + return ( + body.query.includes('sendInteractionEvent') && + body.variables.input.eventName === eventName + ); + }); diff --git a/src/utils/ttsSpeechFetcher.test.ts b/src/utils/ttsSpeechFetcher.test.ts index 34f6719add..5f4a4b5b86 100644 --- a/src/utils/ttsSpeechFetcher.test.ts +++ b/src/utils/ttsSpeechFetcher.test.ts @@ -188,6 +188,77 @@ describe('fetchSpeechAudio', () => { expect(body.data.ssmlEn).toBe('test'); }); + it('英語 SSML の可視テキストからマクロンを除去して送信する', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + result: { + id: 'tts-127', + jaAudioContent: 'QQ==', + enAudioContent: 'QQ==', + }, + }), + }); + + await fetchSpeechAudio({ + ...defaultOptions, + textEn: 'Kiryū and Ōhirashita', + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.data.ssmlEn).toBe('Kiryu and Ohirashita'); + }); + + it('マクロン除去時に SSML タグと IPA 発音記号 (ph 属性) は保持する', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + result: { + id: 'tts-128', + jaAudioContent: 'QQ==', + enAudioContent: 'QQ==', + }, + }), + }); + + await fetchSpeechAudio({ + ...defaultOptions, + textEn: 'the Tōkyō Line', + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + // ph 属性内の IPA (長音 ː) はそのまま、可視テキストの Tōkyō のみ Tokyo になる + expect(body.data.ssmlEn).toBe( + 'the Tokyo Line' + ); + }); + + it('マクロン有無で同じキャッシュを共有する', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + result: { + id: 'tts-129', + jaAudioContent: 'QQ==', + enAudioContent: 'QQ==', + }, + }), + }); + + const first = await fetchSpeechAudio({ + ...defaultOptions, + textEn: 'Tōkyō', + }); + const second = await fetchSpeechAudio({ + ...defaultOptions, + textEn: 'Tokyo', + }); + + expect(first).toEqual(second); + // 2 回目はキャッシュヒットで fetch は 1 回だけ + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + it('PCM MIME の場合は WAV として保存する', async () => { mockFetch.mockResolvedValue({ ok: true, diff --git a/src/utils/ttsSpeechFetcher.ts b/src/utils/ttsSpeechFetcher.ts index 012ea3e3d4..a80d9ca9fa 100644 --- a/src/utils/ttsSpeechFetcher.ts +++ b/src/utils/ttsSpeechFetcher.ts @@ -106,6 +106,22 @@ const normalizeOptional = (val: string | undefined): string => { return trimmed.length > 0 ? trimmed : ''; }; +// ヘボン式ローマ字の長音符(マクロン: Ā ā Ē ē Ī ī Ō ō Ū ū)を素の母音へ落とす。 +// NFD で母音と結合マクロン(U+0304)へ分解し、マクロンだけ取り除いて NFC に戻す。 +// 既存の useIsDifferentStationName と同じ手法。 +const COMBINING_MACRON = String.fromCharCode(0x0304); +const stripMacrons = (text: string): string => + text.normalize('NFD').replaceAll(COMBINING_MACRON, '').normalize('NFC'); + +// TTS API(Azure Speech)はマクロン付き母音を正しく読めず、英語駅名を誤読・無音化 +// することがあるため、SSML の可視テキストからマクロンを除去する。 +// タグ(<...>)は属性ごと保護し、タグ外のテキストだけを対象にすることで、 +// の IPA 発音記号など読みの正確さに関わる値は温存する。 +const stripMacronsFromSsmlText = (ssml: string): string => + ssml.replace(/<[^>]*>|[^<]+/g, (token) => + token.startsWith('<') ? token : stripMacrons(token) + ); + const buildCacheKey = (opts: FetchSpeechOptions): string => `${opts.textJa}\0${opts.textEn}\0${normalizeOptional(opts.jaVoiceName)}\0${normalizeOptional(opts.enVoiceName)}`; @@ -136,7 +152,11 @@ export const fetchSpeechAudio = async ( return null; } - const cacheKey = buildCacheKey(options); + // TTS API はマクロン付き英語駅名を誤読・無音化することがあるため、送信前に + // 英語 SSML の可視テキストからマクロンを除去する(日本語側は元々マクロンを含まない)。 + const sanitizedTextEn = stripMacronsFromSsmlText(textEn); + + const cacheKey = buildCacheKey({ ...options, textEn: sanitizedTextEn }); const cached = fetchCache.get(cacheKey); if (cached) { return cached; @@ -148,7 +168,7 @@ export const fetchSpeechAudio = async ( const reqBody = { data: { ssmlJa: `${textJa.trim()}`, - ssmlEn: `${textEn.trim()}`, + ssmlEn: `${sanitizedTextEn.trim()}`, ...(normalizedJaVoiceName ? { jaVoiceName: normalizedJaVoiceName } : {}), ...(normalizedEnVoiceName ? { enVoiceName: normalizedEnVoiceName } : {}), },