diff --git a/AGENTS.md b/AGENTS.md index ddf3885f33..03ee06f699 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -157,6 +157,7 @@ Command mapping — the skills under `.claude/skills/` follow this table: - Commands executed locally (e.g., `npm run lint && npm test && npm run typecheck`). - Linked issues or tickets. - Visual evidence for UI/UX deltas, each labeled with where the image came from. Any method is acceptable — a device or simulator capture (label it with the device name, e.g., Pixel 8, iPhone 15 Pro), a React Native Web rendering (`npm run web`), or a mockup / generated image. An image that is not a rendering of the implementation must say so in its label, so a reviewer never mistakes an illustration for observed behavior. If no image is attached, state the reason in that section instead of leaving it blank. +- Mockups and illustrations of existing UI must reproduce whatever the real screen branches on. A mockup is read as the spec, so an inaccurate one manufactures a wrong agreement even when the implementation is correct. Before drawing an element that varies by line, theme, or train type — station numbering shape, palette, train-type badge, pass/stop treatment — trace what decides it and take the value for the case you are depicting, instead of reusing the appearance of whichever component file you happened to read for its dimensions. Station numbering shape comes from the API's `lineSymbolShape`, and `src/__fixtures__/station.ts` lists the symbol-to-shape pairs (JR East `JA` / `JB` / `JC` / `JO` / `JS` / `JY` are `SQUARE`; Tokyo Metro and Toei lines are `ROUND`). If you cannot confirm a value, choose a case that does not include that element. - If CI fails, pause reviews until you add root-cause notes plus reproduction steps or open an issue for blocking infrastructure problems. - **Keep PR metadata in sync with the bookmark state.** Whenever you push new commits to an open PR, refresh both the PR title and the body: - **Title**: re-evaluate whether the current title still describes the full scope of the bookmark. If new commits introduce a subject that the title does not cover, propose an updated title and, once approved by the user, apply it via `gh pr edit --title`. @@ -178,6 +179,7 @@ Command mapping — the skills under `.claude/skills/` follow this table: - [ ] Run `npm run lint`, `npm test`, and `npm run typecheck`; record summaries. - [ ] Update documentation (README, docs/, inline comments) if behaviors shift. - [ ] Attach visual evidence for UI changes, each labeled with its source (device name, React Native Web, or an explicit 'not a rendering of the implementation' note for mockups); when no image is attached, state why instead of leaving the section blank. +- [ ] For a mockup or illustration of existing UI, confirm the depicted case's real values for anything that branches by line, theme, or train type (e.g., `lineSymbolShape`) rather than reusing another component's appearance. **For documentation-only tasks** diff --git a/android/app/build.gradle b/android/app/build.gradle index a5a59e6439..4551a6828a 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -129,7 +129,11 @@ android { def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false' shrinkResources enableShrinkResources.toBoolean() minifyEnabled enableMinifyInReleaseBuilds - proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" + // proguard-android.txt は -dontoptimize を含むため R8 の最適化パスが動かず、 + // 収縮と難読化しか効かない。Google Play の技術品質要件 (2027年2月施行) は + // DEX に最適化・圧縮・難読化で最低25%のカバレッジを求めるため、最適化を有効にする。 + // wearable モジュールは元から optimize 版を使っており、そちらへ揃える形になる + proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true' crunchPngs enablePngCrunchInRelease.toBoolean() } @@ -150,13 +154,13 @@ android { dimension "environment" applicationId "me.tinykitten.trainlcd.dev" versionNameSuffix "-dev" - versionCode 100000665 - versionName "10.14.0" + versionCode 100000711 + versionName "10.15.0" } prod { dimension "environment" - versionCode 100000665 - versionName "10.14.0" + versionCode 100000711 + versionName "10.15.0" } } } diff --git a/android/wearable/build.gradle.kts b/android/wearable/build.gradle.kts index fcfe486796..da4552ebba 100644 --- a/android/wearable/build.gradle.kts +++ b/android/wearable/build.gradle.kts @@ -85,13 +85,13 @@ android { dimension = "environment" applicationIdSuffix = ".dev" versionNameSuffix = "-dev" - versionCode = 100000666 - versionName = "10.14.0" + versionCode = 100000712 + versionName = "10.15.0" } create("prod") { dimension = "environment" - versionCode = 100000666 - versionName = "10.14.0" + versionCode = 100000712 + versionName = "10.15.0" } } diff --git a/app.config.ts b/app.config.ts index 32ea914631..4102f631c4 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.14.0', + version: '10.15.0', plugins: [ 'expo-image', 'expo-font', @@ -52,7 +52,7 @@ export default { userInterfaceStyle: 'automatic', // Expo SDK 57 の各モジュール(expo / expo-modules-core ほか)は podspec で iOS 16.4 以上を要求する deploymentTarget: '16.4', - buildNumber: '2862', + buildNumber: '2885', scheme: IS_DEV ? 'CanaryTrainLCD' : 'ProdTrainLCD', bundleIdentifier: IS_DEV ? 'me.tinykitten.trainlcd.dev' @@ -62,7 +62,7 @@ export default { android: { package: IS_DEV ? 'me.tinykitten.trainlcd.dev' : 'me.tinykitten.trainlcd', permissions: [], - versionCode: 100000665, + versionCode: 100000711, }, owner: 'trainlcd', experiments: { diff --git a/assets/images/themes/low-power-sp.webp b/assets/images/themes/low-power-sp.webp new file mode 100644 index 0000000000..feb97f9fce Binary files /dev/null and b/assets/images/themes/low-power-sp.webp differ diff --git a/assets/images/themes/low-power-tablet.webp b/assets/images/themes/low-power-tablet.webp new file mode 100644 index 0000000000..e2239ec215 Binary files /dev/null and b/assets/images/themes/low-power-tablet.webp differ diff --git a/assets/images/themes/tokyo-metro-sp.webp b/assets/images/themes/tokyo-metro-sp.webp index b7d29d3fae..0a7827e454 100644 Binary files a/assets/images/themes/tokyo-metro-sp.webp and b/assets/images/themes/tokyo-metro-sp.webp differ diff --git a/assets/images/themes/tokyo-metro-tablet.webp b/assets/images/themes/tokyo-metro-tablet.webp index e9aa841514..ee93dd8ce2 100644 Binary files a/assets/images/themes/tokyo-metro-tablet.webp and b/assets/images/themes/tokyo-metro-tablet.webp differ diff --git a/assets/translations/en.json b/assets/translations/en.json index ecb2d35197..e2c07f154b 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -54,7 +54,7 @@ "colorSchemeAuto": "Auto (follow system)", "colorSchemeLight": "Light", "colorSchemeDark": "Dark", - "colorSchemeDescription": "Changes the colors of screens such as settings and line selection. \"Auto\" follows your device's dark mode setting. The screen shown while riding keeps the colors of the selected theme. While the LED theme is selected, its own colors are used regardless of this setting.", + "colorSchemeDescription": "Changes the colors of screens such as settings and line selection. \"Auto\" follows your device's dark mode setting. The screen shown while riding keeps the colors of the selected theme, except for Portrait Mode, which follows this setting. While the LED theme is selected, its own colors are used regardless of this setting, except for the Portrait Mode display.", "autoTheme": "Auto", "themeDescriptionAuto": "Theme changes automatically based on the current line.", "tokyoMetroLike": "Tokyo Metro", @@ -273,6 +273,8 @@ "settingsWalkthroughDescription1": "Here you can customize TrainLCD to your liking. You can change the theme, configure auto announcements, and select display languages.", "settingsWalkthroughTitle2": "Theme Settings", "settingsWalkthroughDescription2": "Change your theme here. Choose from various styles like Tokyo Metro, Yamanote Line, or LED display style.", + "settingsWalkthroughColorSchemeTitle": "Appearance", + "settingsWalkthroughColorSchemeDescription": "Switch the app screens between light and dark. Portrait Mode — which shows a portrait layout while you hold your device upright — lives here too.", "settingsWalkthroughTitle3": "Auto Announcements", "settingsWalkthroughDescription3": "Configure auto announcement settings. You can receive voice guidance when arriving at stations.", "settingsWalkthroughTitle4": "Display Languages", @@ -298,10 +300,25 @@ "experimentalSettingsNotice": "Experimental features are under development and may change or be removed without notice.", "portraitModeTitle": "Portrait Mode", "portraitModeDescription": "When enabled, the running screen switches to a layout optimized for portrait orientation while you hold your device upright.", + "portraitPassLabel": "Pass", + "portraitNextStop": "Next %{station}", + "portraitNextStopFrom": "After %{current}: %{station}", + "portraitPassThrough": "Passing %{station}", + "portraitEtaUnit": "min.", + "portraitEtaSoon": "Soon", + "portraitPromoPromptTitle": "Easier to read held upright", + "portraitPromoPromptDescription": "Turn on Portrait Mode and the running screen switches to a portrait layout while you hold your device upright. Rotate back and the screen returns to its usual layout.", + "portraitPromoPromptDismiss": "Not now", + "portraitPromoPromptEnable": "Turn on", + "portraitPromoBannerTitle": "Portrait Mode is available", + "portraitPromoBannerSubtitle": "A layout made for holding your phone upright", + "portraitPromoSpotlightDescription": "While you hold your device upright, the running screen switches to a portrait layout. Tap here to turn it on.", + "portraitPromoSpotlightDismiss": "Later", "telemetryDescription": "Your device's geographic coordinates are sent to our analytics server. The information is used only for analytics.", "batterySettings": "Battery", "powerSavingLocationTitle": "Power-saving location 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.", + "powerSavingLocationDescriptionIOS": "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.", + "powerSavingLocationDescriptionAndroid": "When enabled, location accuracy is lowered to a battery-friendly level, 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.", "destinationAgentEntryTitle": "Don't know the station name?", "destinationAgentEntrySubtitle": "Ask AI where to go", "destinationAgentTitle": "Ask AI for destinations", @@ -318,5 +335,13 @@ "destinationAgentResetShort": "Reset", "destinationAgentResetConfirm": "Clear this conversation?", "destinationAgentSuggestionsError": "Failed to load suggestions", - "destinationAgentRetry": "Retry" + "destinationAgentRetry": "Retry", + "lowPowerTheme": "Lightweight", + "themeDescriptionLowPower": "An original theme with no animation that separates information by brightness alone. It stays readable on small screens and in monochrome.", + "arrivingIn": "到着まで", + "arrivingInEn": "Arriving in", + "stopped": "停車中", + "stoppedEn": "Stopped", + "transferShort": "のりかえ", + "transferShortEn": "Transfer" } diff --git a/assets/translations/ja.json b/assets/translations/ja.json index 7425a1470a..f4c989b279 100644 --- a/assets/translations/ja.json +++ b/assets/translations/ja.json @@ -54,7 +54,7 @@ "colorSchemeAuto": "自動(端末の設定に合わせる)", "colorSchemeLight": "ライト", "colorSchemeDark": "ダーク", - "colorSchemeDescription": "設定や路線選択などの操作画面の配色を変更します。「自動」では端末のダークモード設定に合わせて自動的に切り替わります。走行中の画面はテーマ設定の配色のままです。電光掲示板風テーマを選んでいる間は、この設定にかかわらず電光掲示板風テーマの配色が使われます。", + "colorSchemeDescription": "設定や路線選択などの操作画面の配色を変更します。「自動」では端末のダークモード設定に合わせて自動的に切り替わります。走行中の画面はテーマ設定の配色のままですが、ポートレートモードの表示だけはこの設定に追従します。電光掲示板風テーマを選んでいる間は、ポートレートモードの表示を除き、この設定にかかわらず電光掲示板風テーマの配色が使われます。", "autoTheme": "自動", "themeDescriptionAuto": "路線に応じて自動的にテーマが変わります。", "tokyoMetroLike": "東京メトロ風", @@ -274,6 +274,8 @@ "settingsWalkthroughDescription1": "ここでは、TrainLCDをあなた好みにカスタマイズできます。テーマの変更、自動アナウンスの設定、表示言語の選択が行えます。", "settingsWalkthroughTitle2": "テーマ設定", "settingsWalkthroughDescription2": "ここからテーマを変更できます。東京メトロ風、山手線風、電光掲示板風など、様々なスタイルから選べます。", + "settingsWalkthroughColorSchemeTitle": "外観", + "settingsWalkthroughColorSchemeDescription": "操作画面の配色をライト・ダークで切り替えられます。端末を縦に持っている間だけ縦画面用の表示にするポートレートモードも、ここにあります。", "settingsWalkthroughTitle3": "自動アナウンス", "settingsWalkthroughDescription3": "自動アナウンス機能の設定ができます。駅到着時に音声で案内を受け取ることができます。", "settingsWalkthroughTitle4": "表示言語", @@ -299,10 +301,25 @@ "experimentalSettingsNotice": "試験的機能は開発中のため、予告なく変更または削除されることがあります。", "portraitModeTitle": "ポートレートモード", "portraitModeDescription": "有効にすると、走行画面で端末を縦向きにした際に、縦画面に最適化されたデザインで表示します。", + "portraitPassLabel": "通過", + "portraitNextStop": "つぎは%{station}", + "portraitNextStopFrom": "%{current}のつぎは%{station}", + "portraitPassThrough": "%{station}を通過中", + "portraitEtaUnit": "分", + "portraitEtaSoon": "まもなく", + "portraitPromoPromptTitle": "縦のままで見やすくできます", + "portraitPromoPromptDescription": "ポートレートモードをオンにすると、端末を縦に持っている間だけ縦画面用の表示に切り替わります。横に戻せば今までどおりです。", + "portraitPromoPromptDismiss": "今はしない", + "portraitPromoPromptEnable": "オンにする", + "portraitPromoBannerTitle": "ポートレートモードが使えます", + "portraitPromoBannerSubtitle": "縦持ちのまま見やすい表示に", + "portraitPromoSpotlightDescription": "端末を縦に持っている間だけ、縦画面用の表示に切り替わります。ここをタップしてオンにしてください。", + "portraitPromoSpotlightDismiss": "あとで", "telemetryDescription": "お使いの端末の地理的座標を解析用サーバに送信します。送信された情報は解析以外に使用されません。", "batterySettings": "バッテリー", "powerSavingLocationTitle": "省電力測位モード", - "powerSavingLocationDescription": "有効にすると、測位精度を電池優先まで下げ、停車中はiOSが測位を自動休止して、長時間の乗車での電池消費と発熱をさらに減らします。精度の低下により駅の判定や到着案内が遅れたりずれたりする場合があります。位置情報の更新頻度の緩和は標準設定に組み込まれています。端末の省電力モード中は自動的に有効になります。", + "powerSavingLocationDescriptionIOS": "有効にすると、測位精度を電池優先まで下げ、停車中はiOSが測位を自動休止して、長時間の乗車での電池消費と発熱をさらに減らします。精度の低下により駅の判定や到着案内が遅れたりずれたりする場合があります。位置情報の更新頻度の緩和は標準設定に組み込まれています。端末の省電力モード中は自動的に有効になります。", + "powerSavingLocationDescriptionAndroid": "有効にすると、測位精度を電池優先まで下げ、長時間の乗車での電池消費と発熱をさらに減らします。精度の低下により駅の判定や到着案内が遅れたりずれたりする場合があります。位置情報の更新頻度の緩和は標準設定に組み込まれています。端末の省電力モード中は自動的に有効になります。", "destinationAgentEntryTitle": "駅名がわからなくても大丈夫", "destinationAgentEntrySubtitle": "AIに行き先を相談する", "destinationAgentTitle": "AIに行き先を相談", @@ -319,5 +336,13 @@ "destinationAgentResetShort": "リセット", "destinationAgentResetConfirm": "会話履歴を消去しますか?", "destinationAgentSuggestionsError": "候補の取得に失敗しました", - "destinationAgentRetry": "再試行" + "destinationAgentRetry": "再試行", + "lowPowerTheme": "ライトウェイト", + "themeDescriptionLowPower": "アニメーションを使わず、明度の差だけで情報を分ける独自テーマです。小さい画面やモノクロ表示でも読めます。", + "arrivingIn": "到着まで", + "arrivingInEn": "Arriving in", + "stopped": "停車中", + "stoppedEn": "Stopped", + "transferShort": "のりかえ", + "transferShortEn": "Transfer" } diff --git a/docs/spec/tts/remote-tts.md b/docs/spec/tts/remote-tts.md index 74428420c4..b8858423ca 100644 --- a/docs/spec/tts/remote-tts.md +++ b/docs/spec/tts/remote-tts.md @@ -61,6 +61,59 @@ Android のダッキングはオーディオフォーカスの要求でしか起 このモジュールはネイティブコードを含むため、反映にはネイティブビルドが必要で OTA アップデートでは配信されない。 +## 端末内蔵 TTS の音声選択 + +端末内蔵 TTS はどの音声で合成するかで音質が大きく変わる。選択は +`selectBestVoiceIdentifier()`(`src/utils/nativeTtsVoice.ts`)が行い、言語ごとに +1 つの音声識別子を決めて `expo-speech` へ渡す。 + +Android は音声の明示指定が必須である。`expo-speech` の Android 実装は `speak` の +`language` を `Locale` へ渡すが、音声データが端末に無い言語では `setLanguage` が +`LANG_MISSING_DATA` となり端末既定言語へフォールバックし、英語文が日本語音声で +合成されてしまう。`voice` を指定すれば `setVoice` が言語設定ごと上書きするため、 +この不備の影響を受けない。そのため Android では高品質音声が無くても既定品質の +ローカル音声を明示指定する(`allowDefaultQuality`)。iOS はユーザーが OS 設定で +選んだ既定音声を尊重し、拡張(Enhanced)/ プレミアム(Premium)音声がある場合だけ +明示指定する。 + +`expo-speech` が返す `quality` は `Enhanced` / `Default` の 2 値に丸められており、 +Google TTS の日本語ローカル音声のように `QUALITY_NORMAL` へ横並びになる端末では +優劣を判定できない。判断材料が尽きると識別子のアルファベット順で決まってしまい、 +品質と無関係に `ja-jp-x-htm-local` が常に選ばれ、ユーザーが端末設定で選んだ音声も +無視される。これを避けるため `patches/expo-speech+57.0.1.patch` で +`android.speech.tts.Voice` の生の情報を JS へ渡している。 + +| フィールド | 由来 | 用途 | +| --- | --- | --- | +| `qualityScore` | `Voice.getQuality()`(100〜500) | ローカル音声同士の優劣を判定する | +| `isDefault` | `TextToSpeech.defaultVoice` と一致するか | 端末設定でのユーザーの選択を尊重する | +| `networkRequired` | `Voice.isNetworkConnectionRequired()` | 識別子の `network` 判定より正確にローカル音声を選ぶ | +| `notInstalled` | `Voice.getFeatures()` の `notInstalled` | 音声データ未取得で合成できない音声を避ける | + +いずれも iOS では返らないため、iOS では従来どおり識別子と `quality` で判定する。 + +優先順は次のとおりで、上から順に比較して差がついた時点で決まる。 + +1. インストール済み — 音声データが無い音声は合成できない +1. ローカル音声 — 乗車中はトンネル等で接続が切れるため、ネットワーク音声は最後の手段 +1. 地域一致 — `en-US` 要求時に `en-GB` より `en-US` を選ぶ +1. 端末既定音声 — ユーザーの明示的な選択を機械的な優劣より優先する +1. `qualityScore` 降順 — Android の生の品質値 +1. 識別子ベースの品質判定 — iOS の `premium` > `enhanced` > その他 +1. 識別子の昇順 — 実行ごとに音声が変わらないようにする最後のタイブレーク + +最下段まで差がつかない場合にだけ識別子順に落ちる。ここへ到達するのは判断材料が +出尽くしたときだけなので、品質と無関係な順序で音声が決まる範囲を最小化している。 + +選択結果は起動時に 1 度だけ `[useNativeSpeechEngine] Selected voices: ...` として +ログへ残す。端末ごとに音声のラインナップが違い、音質の問い合わせは実機でどの音声が +選ばれたかが分からないと切り分けられないため。 + +生メタデータの露出は `expo-speech` へのネイティブ patch なので、反映には +ネイティブビルドが必要で OTA アップデートでは配信されない。patch が当たっていない +バイナリでは各フィールドが `undefined` となり、従来どおり識別子順のタイブレークへ +フォールバックする。 + ## テキストの前処理 TTS テンプレートは SSML 断片を生成するが、Cloud TTS(`input.text`)も `expo-speech` も diff --git a/ios/TrainLCD.xcodeproj/project.pbxproj b/ios/TrainLCD.xcodeproj/project.pbxproj index 743876babe..1f60c04b2b 100644 --- a/ios/TrainLCD.xcodeproj/project.pbxproj +++ b/ios/TrainLCD.xcodeproj/project.pbxproj @@ -2462,7 +2462,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2862; + CURRENT_PROJECT_VERSION = 2885; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; @@ -2502,7 +2502,7 @@ CODE_SIGN_ENTITLEMENTS = ProdTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2862; + CURRENT_PROJECT_VERSION = 2885; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Prod/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = TrainLCD; @@ -2561,7 +2561,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2862; + CURRENT_PROJECT_VERSION = 2885; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -2617,7 +2617,7 @@ "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", "\"$(inherited)\"", ); - MARKETING_VERSION = 10.14.0; + MARKETING_VERSION = 10.15.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; @@ -2667,7 +2667,7 @@ CODE_SIGN_ENTITLEMENTS = TrainLCD/trainlcd.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = YES; - CURRENT_PROJECT_VERSION = 2862; + CURRENT_PROJECT_VERSION = 2885; CXX = "$(REACT_NATIVE_PATH)/scripts/xcode/ccache-clang++.sh"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -2719,7 +2719,7 @@ "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", "\"$(inherited)\"", ); - MARKETING_VERSION = 10.14.0; + MARKETING_VERSION = 10.15.0; MTL_ENABLE_DEBUG_INFO = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(inherited)"; @@ -2747,7 +2747,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2862; + CURRENT_PROJECT_VERSION = 2885; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -2787,7 +2787,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryTrainLCD.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2862; + CURRENT_PROJECT_VERSION = 2885; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; INFOPLIST_FILE = TrainLCD/Schemes/Dev/Info.plist; @@ -2999,7 +2999,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/CanaryRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2862; + CURRENT_PROJECT_VERSION = 2885; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3051,7 +3051,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2862; + CURRENT_PROJECT_VERSION = 2885; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3102,7 +3102,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/ProdWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2862; + CURRENT_PROJECT_VERSION = 2885; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3160,7 +3160,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2862; + CURRENT_PROJECT_VERSION = 2885; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3211,7 +3211,7 @@ CODE_SIGN_ENTITLEMENTS = WatchWidget/CanaryWatchWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2862; + CURRENT_PROJECT_VERSION = 2885; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3268,7 +3268,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2862; + CURRENT_PROJECT_VERSION = 2885; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -3317,7 +3317,7 @@ CODE_SIGN_ENTITLEMENTS = RideSessionActivity/ProdRideSessionActivity.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2862; + CURRENT_PROJECT_VERSION = 2885; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3369,7 +3369,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2862; + CURRENT_PROJECT_VERSION = 2885; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -3588,7 +3588,7 @@ CODE_SIGN_ENTITLEMENTS = ProdAppClip/ProdAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2862; + CURRENT_PROJECT_VERSION = 2885; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3644,7 +3644,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2862; + CURRENT_PROJECT_VERSION = 2885; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3694,7 +3694,7 @@ CODE_SIGN_ENTITLEMENTS = CanaryAppClip/CanaryAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2862; + CURRENT_PROJECT_VERSION = 2885; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3718,7 +3718,7 @@ "@executable_path/Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 10.14.0; + MARKETING_VERSION = 10.15.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; @@ -3752,7 +3752,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2862; + CURRENT_PROJECT_VERSION = 2885; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = E6R2G33Z36; ENABLE_USER_SCRIPT_SANDBOXING = NO; @@ -3772,7 +3772,7 @@ "@executable_path/Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 10.14.0; + MARKETING_VERSION = 10.15.0; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PODS_ROOT = "${SRCROOT}/Pods"; diff --git a/jest.setup.js b/jest.setup.js index 9e86e8cd31..8ab6a62497 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -52,6 +52,12 @@ jest.mock("react-native-reanimated", () => { runOnJS: jest.fn((fn) => fn), Easing: { ease: jest.fn(), + linear: jest.fn(), + quad: jest.fn(), + in: jest.fn((fn) => fn), + out: jest.fn((fn) => fn), + inOut: jest.fn((fn) => fn), + bezier: jest.fn(() => jest.fn()), }, }; }); diff --git a/package-lock.json b/package-lock.json index 3e874a5957..9c25abbe78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "trainlcd", - "version": "10.14.0", + "version": "10.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trainlcd", - "version": "10.14.0", + "version": "10.15.0", "hasInstallScript": true, "dependencies": { "@expo-google-fonts/roboto": "^0.2.3", @@ -6285,9 +6285,9 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.8.13", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", - "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -7112,9 +7112,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.20", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz", - "integrity": "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==", + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -7221,9 +7221,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", "funding": [ { "type": "opencollective", @@ -7240,11 +7240,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" @@ -7384,9 +7384,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001788", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", - "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "funding": [ { "type": "opencollective", @@ -8461,9 +8461,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.340", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz", - "integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==", + "version": "1.5.422", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz", + "integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==", "license": "ISC" }, "node_modules/emittery": { @@ -9853,9 +9853,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "funding": [ { "type": "github", @@ -14104,10 +14104,13 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.37", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", - "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", - "license": "MIT" + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/node-stream-zip": { "version": "1.15.0", @@ -17624,9 +17627,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "funding": [ { "type": "opencollective", diff --git a/package.json b/package.json index ac44857615..0d89bbb3f7 100644 --- a/package.json +++ b/package.json @@ -169,5 +169,5 @@ } }, "name": "trainlcd", - "version": "10.14.0" + "version": "10.15.0" } diff --git a/patches/expo-speech+57.0.1.patch b/patches/expo-speech+57.0.1.patch index 3b7379616d..94d4c1a965 100644 --- a/patches/expo-speech+57.0.1.patch +++ b/patches/expo-speech+57.0.1.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/expo-speech/android/src/main/java/expo/modules/speech/SpeechModule.kt b/node_modules/expo-speech/android/src/main/java/expo/modules/speech/SpeechModule.kt -index 9ca869c..81d37ec 100644 +index 9ca869c..077cb21 100644 --- a/node_modules/expo-speech/android/src/main/java/expo/modules/speech/SpeechModule.kt +++ b/node_modules/expo-speech/android/src/main/java/expo/modules/speech/SpeechModule.kt @@ -4,6 +4,7 @@ import android.os.Bundle @@ -20,7 +20,52 @@ index 9ca869c..81d37ec 100644 class SpeechModule : Module() { private val delayedUtterances: Queue = ArrayDeque() private val delayedGetVoices: Queue = ArrayDeque() -@@ -103,24 +107,48 @@ class SpeechModule : Module() { +@@ -83,6 +87,14 @@ class SpeechModule : Module() { + throw SpeechUnableToGetVoicesException(err) + } + ++ // TrainLCD patch: 端末設定で選ばれているエンジン既定音声を JS 側へ伝える。 ++ // 一部エンジンは defaultVoice の取得で例外を投げるため防御する。 ++ val defaultVoiceName = try { ++ textToSpeech.defaultVoice?.name ++ } catch (_: Exception) { ++ null ++ } ++ + return nativeVoices.map { + val quality = if (it.quality > Voice.QUALITY_NORMAL) { + VoiceQuality.ENHANCED +@@ -90,11 +102,28 @@ class SpeechModule : Module() { + VoiceQuality.DEFAULT + } + ++ // TrainLCD patch: features / isNetworkConnectionRequired はエンジン実装に ++ // よっては例外を投げうるため、取得できない場合は保守的な既定値へ倒す。 ++ val networkRequired = try { ++ it.isNetworkConnectionRequired ++ } catch (_: Exception) { ++ false ++ } ++ val notInstalled = try { ++ it.features?.contains(TextToSpeech.Engine.KEY_FEATURE_NOT_INSTALLED) == true ++ } catch (_: Exception) { ++ false ++ } ++ + VoiceRecord( + identifier = it.name, + name = it.name, + quality = quality, +- language = LanguageUtils.getISOCode(it.locale) ++ language = LanguageUtils.getISOCode(it.locale), ++ qualityScore = it.quality, ++ isDefault = defaultVoiceName != null && it.name == defaultVoiceName, ++ networkRequired = networkRequired, ++ notInstalled = notInstalled + ) + } + } +@@ -103,24 +132,48 @@ class SpeechModule : Module() { options.pitch?.let(textToSpeech::setPitch) options.rate?.let(textToSpeech::setSpeechRate) @@ -48,11 +93,7 @@ index 9ca869c..81d37ec 100644 } - } ?: Locale.getDefault() + } - -- options.voice?.let { voiceName -> -- textToSpeech.voices -- .firstOrNull { it.name == voiceName } -- ?.let(textToSpeech::setVoice) ++ + if (matchedVoice != null) { + textToSpeech.setVoice(matchedVoice) + } else { @@ -70,7 +111,11 @@ index 9ca869c..81d37ec 100644 + availability != TextToSpeech.LANG_MISSING_DATA && + availability != TextToSpeech.LANG_NOT_SUPPORTED + } -+ + +- options.voice?.let { voiceName -> +- textToSpeech.voices +- .firstOrNull { it.name == voiceName } +- ?.let(textToSpeech::setVoice) + when { + isUsable(exactLocale) -> exactLocale + isUsable(languageOnlyLocale) -> languageOnlyLocale @@ -85,3 +130,28 @@ index 9ca869c..81d37ec 100644 } val params = Bundle().apply { +diff --git a/node_modules/expo-speech/android/src/main/java/expo/modules/speech/VoiceRecord.kt b/node_modules/expo-speech/android/src/main/java/expo/modules/speech/VoiceRecord.kt +index 480ce46..e85ad12 100644 +--- a/node_modules/expo-speech/android/src/main/java/expo/modules/speech/VoiceRecord.kt ++++ b/node_modules/expo-speech/android/src/main/java/expo/modules/speech/VoiceRecord.kt +@@ -15,5 +15,19 @@ data class VoiceRecord( + @Field val identifier: String, + @Field val name: String, + @Field val quality: VoiceQuality, +- @Field val language: String ++ @Field val language: String, ++ // TrainLCD patch: 音声選択の精度を上げるための Android 固有メタデータ。 ++ // quality は ENHANCED / DEFAULT の 2 値へ丸められており、ローカル音声同士の ++ // 優劣を判定できない(Google TTS の日本語ローカル音声は軒並み QUALITY_NORMAL ++ // に並ぶ)。android.speech.tts.Voice が持つ生の情報を併せて渡すことで、 ++ // JS 側が識別子のアルファベット順という無根拠なタイブレークに頼らずに済む。 ++ // ++ // - qualityScore: Voice.getQuality() の生値 (QUALITY_VERY_LOW=100 〜 VERY_HIGH=500) ++ // - isDefault: 端末設定で選ばれているエンジン既定音声か ++ // - networkRequired: 合成にネットワーク接続が必要か(識別子の 'network' 判定より正確) ++ // - notInstalled: 音声データが未ダウンロードで、指定しても合成できない状態か ++ @Field val qualityScore: Int, ++ @Field val isDefault: Boolean, ++ @Field val networkRequired: Boolean, ++ @Field val notInstalled: Boolean + ) : Record diff --git a/src/__mocks__/react-native-reanimated-mock.js b/src/__mocks__/react-native-reanimated-mock.js index b33bd506cc..7dd114596c 100644 --- a/src/__mocks__/react-native-reanimated-mock.js +++ b/src/__mocks__/react-native-reanimated-mock.js @@ -36,6 +36,7 @@ const ReanimatedMock = { useAnimatedRef: () => ({ current: null }), useAnimatedScrollHandler: () => jest.fn(), useAnimatedGestureHandler: () => jest.fn(), + useReducedMotion: () => false, LinearTransition: linearTransition, FadeIn: animationBuilder, FadeInDown: animationBuilder, diff --git a/src/components/FooterTabBar.tsx b/src/components/FooterTabBar.tsx index 3195840051..253e93ccae 100644 --- a/src/components/FooterTabBar.tsx +++ b/src/components/FooterTabBar.tsx @@ -21,9 +21,11 @@ import Animated, { } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { LED_THEME_BG_COLOR } from '~/constants'; +import { usePortraitPromoAppearanceHint } from '~/hooks/usePortraitPromoAppearanceHint'; import { useAppColors } from '~/providers/AppColorsProvider'; import { isLEDThemeAtom } from '~/store/atoms/theme'; import { LIQUID_GLASS_AVAILABLE } from '~/utils/liquidGlass'; +import NewFeatureDot, { NEW_FEATURE_DOT_SIZE_SMALL } from './NewFeatureDot'; type FooterTab = 'home' | 'search' | 'settings'; @@ -126,6 +128,12 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', }, + // アイコンの右肩に載せる未読ドット + tabBadge: { + position: 'absolute', + top: 8, + right: 8, + }, // タブバー全体で 1 つだけ描画する共有ピル。translateX でアクティブタブ間をスライドする activePill: { position: 'absolute', @@ -201,6 +209,7 @@ const FooterTabBar: React.FC = ({ const route = useRoute(); const isLEDTheme = useAtomValue(isLEDThemeAtom); const colors = useAppColors(); + const showPortraitPromoHint = usePortraitPromoAppearanceHint(); // タブ間の移動で履歴を積まないよう navigate ではなく replace で遷移する。 // 同一画面への replace は画面の再マウントになるだけなので無視する @@ -367,6 +376,17 @@ const FooterTabBar: React.FC = ({ active === 'settings' ? ACTIVE_ICON_COLOR : colors.tabIconInactive } /> + {/* 「設定のどこかに新しいものがある」という道しるべ。目的地(設定リストの + 「外観」行)側だけを脈打たせ、こちらは静止させて視線を割らない */} + {showPortraitPromoHint ? ( + + + + ) : null} ); diff --git a/src/components/FxTrimMemoryOnBackground.tsx b/src/components/FxTrimMemoryOnBackground.tsx new file mode 100644 index 0000000000..d71342f5c5 --- /dev/null +++ b/src/components/FxTrimMemoryOnBackground.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { useTrimMemoryOnBackground } from '~/hooks/useTrimMemoryOnBackground'; + +/** + * バックグラウンド遷移時に画像のメモリキャッシュを破棄するレンダーレスの副作用ホスト。 + * + * テーマプレビューは設定画面 (ThemeSettings) に、乗車中の画面は Permitted 配下にあり + * どちらか一方のツリーに置くと取りこぼすため、アプリのルート直下でマウントする。 + */ +const FxTrimMemoryOnBackground: React.FC = () => { + useTrimMemoryOnBackground(); + return null; +}; + +export default React.memo(FxTrimMemoryOnBackground); diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 8c0151c60e..41bae4f86f 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -9,6 +9,7 @@ import HeaderJL from './HeaderJL'; import HeaderJRKyushu from './HeaderJRKyushu'; import HeaderJRWest from './HeaderJRWest'; import HeaderLED from './HeaderLED'; +import HeaderLowPower from './HeaderLowPower'; import HeaderOdakyu from './HeaderOdakyu'; import HeaderSaikyo from './HeaderSaikyo'; import HeaderTokyoMetro from './HeaderTokyoMetro'; @@ -45,6 +46,8 @@ const Header = () => { return ; case APP_THEME.E231: return ; + case APP_THEME.LOW_POWER: + return ; default: return null; } diff --git a/src/components/HeaderLowPower.test.tsx b/src/components/HeaderLowPower.test.tsx new file mode 100644 index 0000000000..7651c1b800 --- /dev/null +++ b/src/components/HeaderLowPower.test.tsx @@ -0,0 +1,223 @@ +import { render } from '@testing-library/react-native'; +import { createMockHeaderProps } from '~/__fixtures__/headerProps'; +import HeaderLowPower from './HeaderLowPower'; + +const NO_INSETS = { top: 0, right: 0, bottom: 0, left: 0 }; + +jest.mock('~/hooks', () => ({ + useLowPowerLayout: jest.fn(() => ({ + width: 720, + height: 360, + scale: 1, + insets: { top: 0, right: 0, bottom: 0, left: 0 }, + })), + useTransferLines: jest.fn(() => []), + useEstimateArrivalTimes: jest.fn(() => ({ route: null })), + useEstimatedMinutesByStationId: jest.fn(() => new Map()), +})); + +jest.mock('~/translation', () => ({ + translate: jest.fn((key: string) => { + const dict: Record = { + local: '各駅停車', + localEn: 'Local', + arrivingIn: '到着まで', + arrivingInEn: 'Arriving in', + stopped: '停車中', + stoppedEn: 'Stopped', + soon: 'まもなく', + soonEn: 'Soon', + transferShort: 'のりかえ', + transferShortEn: 'Transfer', + }; + return dict[key] ?? key; + }), +})); + +const { + useEstimatedMinutesByStationId, + useLowPowerLayout, + useTransferLines, +} = require('~/hooks'); + +describe('HeaderLowPower', () => { + const props = createMockHeaderProps(); + const nextStationId = props.nextStation?.id as number; + + beforeEach(() => { + useLowPowerLayout.mockReturnValue({ + width: 720, + height: 360, + scale: 1, + insets: NO_INSETS, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const setEstimatedMinutes = (minutes: number | null) => { + useEstimatedMinutesByStationId.mockReturnValue( + minutes == null ? new Map() : new Map([[nextStationId, minutes]]) + ); + }; + + it('次駅表示では状態・駅名・到着予測を出す', () => { + setEstimatedMinutes(2); + const { getByText } = render( + + ); + + expect(getByText('次は')).toBeTruthy(); + expect(getByText('到着まで')).toBeTruthy(); + expect(getByText('2')).toBeTruthy(); + expect(getByText('分')).toBeTruthy(); + }); + + it('到着予測が取れないときは到着まわりを描画しない', () => { + setEstimatedMinutes(null); + const { queryByText } = render( + + ); + + expect(queryByText('到着まで')).toBeNull(); + expect(queryByText('分')).toBeNull(); + }); + + it('接近中は分数の代わりに「まもなく」を出す', () => { + setEstimatedMinutes(1); + const { getAllByText, queryByText } = render( + + ); + + // 状態ラベルと到着欄の両方が「まもなく」になる + expect(getAllByText('まもなく')).toHaveLength(2); + expect(queryByText('到着まで')).toBeNull(); + }); + + it('停車中は「停車中」を出す', () => { + setEstimatedMinutes(2); + const { getByText, queryByText } = render( + + ); + + expect(getByText('停車中')).toBeTruthy(); + expect(queryByText('到着まで')).toBeNull(); + }); + + it('英語表示では英語のラベルを使う', () => { + setEstimatedMinutes(3); + const { getByText } = render( + + ); + + expect(getByText('Arriving in')).toBeTruthy(); + expect(getByText('min.')).toBeTruthy(); + }); + + it('乗換路線があるときだけ乗換ブロックを出す', () => { + setEstimatedMinutes(2); + const { queryByText, rerender, getByText } = render( + + ); + expect(queryByText('のりかえ')).toBeNull(); + + useTransferLines.mockReturnValue([ + { nameShort: '東急大井町線', nameRoman: 'Tokyu Oimachi Line' }, + ]); + rerender( + + ); + + expect(getByText('のりかえ')).toBeTruthy(); + expect(getByText('東急大井町線')).toBeTruthy(); + }); + + it('2行に収まる乗換路線はすべて並べる', () => { + setEstimatedMinutes(2); + useTransferLines.mockReturnValue( + ['A線', 'B線', 'C線', 'D線', 'E線'].map((nameShort) => ({ + nameShort, + nameRoman: nameShort, + })) + ); + + const { getByText } = render( + + ); + + expect(getByText('A線・B線・C線・D線・E線')).toBeTruthy(); + }); + + it('2行に収まらない乗換路線は収まる件数まで減らして「他N線」に畳む', () => { + setEstimatedMinutes(2); + useTransferLines.mockReturnValue( + [ + '東京メトロ丸ノ内線', + '東京メトロ千代田線', + '東京メトロ半蔵門線', + '東京メトロ東西線', + '都営三田線', + ].map((nameShort) => ({ nameShort, nameRoman: nameShort })) + ); + + const { getByText } = render( + + ); + + expect( + getByText('東京メトロ丸ノ内線・東京メトロ千代田線 他3線') + ).toBeTruthy(); + }); + + it('セーフエリアぶんを外周の余白として確保する', () => { + setEstimatedMinutes(2); + useLowPowerLayout.mockReturnValue({ + width: 660, + height: 326, + scale: 326 / 360, + insets: { top: 34, right: 21, bottom: 0, left: 39 }, + }); + + const { getByTestId } = render( + + ); + + const root = getByTestId('low-power-header-root'); + expect(root.props.style).toEqual( + expect.objectContaining({ paddingTop: 34 }) + ); + // 行先未選択なので実効高さ 326 の 1/3 に、上端のセーフエリアぶんが乗る + expect(root.props.style).toEqual( + expect.objectContaining({ height: 34 + 326 / 3 }) + ); + + const gutter = 16 * (326 / 360); + expect(getByTestId('low-power-header-top-bar').props.style).toEqual( + expect.objectContaining({ + paddingLeft: gutter + 39, + paddingRight: gutter + 21, + }) + ); + }); +}); diff --git a/src/components/HeaderLowPower.tsx b/src/components/HeaderLowPower.tsx new file mode 100644 index 0000000000..8e0ec1916f --- /dev/null +++ b/src/components/HeaderLowPower.tsx @@ -0,0 +1,393 @@ +import React, { useMemo } from 'react'; +import { View } from 'react-native'; +import { + useEstimateArrivalTimes, + useEstimatedMinutesByStationId, + useLowPowerLayout, + useTransferLines, +} from '~/hooks'; +import { FONTS, LOW_POWER_THEME_COLORS, parenthesisRegexp } from '../constants'; +import { translate } from '../translation'; +import type { CommonHeaderProps } from './Header.types'; +import Typography from './Typography'; + +const { background, primary, secondary, muted, accent } = + LOW_POWER_THEME_COLORS; + +/** 乗換路線名に割ける行数。ヘッダー右カラムの取り分がこれで決まる */ +const TRANSFER_TEXT_LINES = 2; +/** 乗換路線名のフォントサイズ(拡大率を掛ける前)。表示と文字数見積もりで共有する */ +const TRANSFER_TEXT_FONT_SIZE = 15; + +/** + * 全角1文字ぶんを1カラムとして数える。ASCII は半角なので 0.5 で見積もる。 + * 行送りを実測せずに「何件まで並べられるか」を決めるための近似で、 + * 日本語は任意位置で折り返せるため合計カラム数がそのまま行数の目安になる。 + */ +const measureColumns = (text: string): number => { + let columns = 0; + for (const char of text) { + columns += (char.codePointAt(0) ?? 0) < 0x100 ? 0.5 : 1; + } + return columns; +}; + +/** + * 低消費電力テーマ(#3697)のヘッダー。 + * + * 上段に路線・種別・行先、中段に次駅と到着予測を置く。アニメーションを + * 一切持たないので、他テーマのような useHeaderAnimation は使わずに + * 現在の値をそのまま描画する。寸法はすべて設計基準(720x360dp)からの + * 拡大率で決まるため、Pixel 3 のような低解像度端末でも設計どおりに収まる。 + */ +const HeaderLowPower: React.FC = ({ + currentStation, + currentLine, + nextStation, + selectedBound, + headerState, + stationText, + stateText, + boundText, + currentStationNumber, + trainType, + isJapaneseState, +}) => { + // 寸法はセーフエリアを除いた実効領域から起こす。ノッチやホームインジケータへ + // 文字が潜り込まないよう、外周の余白として insets をそのまま足し込む + const { width, height, scale, insets } = useLowPowerLayout(); + const transferLines = useTransferLines(); + const { route: estimatedRoute } = useEstimateArrivalTimes(); + const estimatedMinutesByStationId = + useEstimatedMinutesByStationId(estimatedRoute); + + // 行先未選択時は路線図側に出す情報がないため、ヘッダーの取り分を減らす + const rootHeight = + insets.top + (selectedBound ? (height * 2) / 3 : height / 3); + + const metrics = useMemo(() => { + const gutter = 16 * scale; + // 右カラムは基準幅を保ちつつ、4:3 のタブレットで広がりすぎないよう画面幅で頭打ちにする + const sideColumnWidth = Math.min(width * 0.3, 190 * scale); + return { + gutter, + sideColumnWidth, + hairline: Math.max(1, scale), + topBarHeight: 40 * scale, + nameAreaWidth: width - gutter * 4 - Math.max(1, scale) - sideColumnWidth, + }; + }, [width, scale]); + + const stoppingState = headerState.split('_')[0]; + + // ヘッダーが今どの駅を指しているか。ローマ字の併記もこの駅から引く + const displayedStation = + stoppingState === 'CURRENT' ? currentStation : nextStation; + const subStationName = isJapaneseState + ? displayedStation?.nameRoman + : displayedStation?.name; + + const nameFontSize = useMemo(() => { + const length = stationText.length || 1; + // 全角はおよそ1em、ラテン文字はおよそ0.6em幅として器に収まる最大サイズを選ぶ。 + // 見積もりを超えた分は adjustsFontSizeToFit が縮めて吸収する + const widthPerChar = isJapaneseState ? 1 : 0.6; + const fitted = metrics.nameAreaWidth / (length * widthPerChar); + return Math.max(20 * scale, Math.min(76 * scale, fitted)); + }, [isJapaneseState, metrics.nameAreaWidth, scale, stationText.length]); + + const trainTypeText = isJapaneseState + ? (trainType?.name ?? translate('local')) + : (trainType?.nameRoman ?? translate('localEn')); + + const lineText = ( + (isJapaneseState ? currentLine?.nameShort : currentLine?.nameRoman) ?? '' + ).replace(parenthesisRegexp, ''); + + const eta = useMemo(() => { + const unit = isJapaneseState ? '分' : 'min.'; + if (stoppingState === 'CURRENT') { + return { + label: '', + value: translate(isJapaneseState ? 'stopped' : 'stoppedEn'), + unit: '', + emphasized: false, + }; + } + if (stoppingState === 'ARRIVING') { + return { + label: '', + value: translate(isJapaneseState ? 'soon' : 'soonEn'), + unit: '', + emphasized: false, + }; + } + const minutes = + nextStation?.id != null + ? estimatedMinutesByStationId.get(nextStation.id) + : null; + if (minutes == null) { + return { label: '', value: '', unit: '', emphasized: false }; + } + return { + label: translate(isJapaneseState ? 'arrivingIn' : 'arrivingInEn'), + value: String(Math.round(minutes)), + unit, + emphasized: true, + }; + }, [ + estimatedMinutesByStationId, + isJapaneseState, + nextStation?.id, + stoppingState, + ]); + + const transferText = useMemo(() => { + if (!transferLines.length) { + return ''; + } + const names = transferLines + .map((line) => + ((isJapaneseState ? line.nameShort : line.nameRoman) ?? '').replace( + parenthesisRegexp, + '' + ) + ) + .filter((name) => name.length); + if (!names.length) { + return ''; + } + + const separator = isJapaneseState ? '・' : ', '; + const buildText = (count: number) => { + const joined = names.slice(0, count).join(separator); + const rest = names.length - count; + if (!rest) { + return joined; + } + return isJapaneseState ? `${joined} 他${rest}線` : `${joined} +${rest}`; + }; + + // 並べる件数は固定せず、右カラム2行に収まる文字数から決める。あふれた分は + // 必ず「他N線」へ畳まれるので、末尾が三点リーダーで切れることがない + const budget = + (metrics.sideColumnWidth / (TRANSFER_TEXT_FONT_SIZE * scale)) * + TRANSFER_TEXT_LINES; + for (let count = names.length; count > 1; count--) { + const text = buildText(count); + if (measureColumns(text) <= budget) { + return text; + } + } + // 1件でも溢れる駅はこれ以上畳みようがないので、そのまま返して省略に委ねる + return buildText(1); + }, [isJapaneseState, metrics.sideColumnWidth, scale, transferLines]); + + return ( + + + + {trainTypeText} + + + {lineText} + + + + {boundText} + + + + + + {stateText.length ? ( + + {stateText.replaceAll('\n', ' ')} + + ) : null} + + + {stationText} + + + + {currentStationNumber?.stationNumber ? ( + + {currentStationNumber.stationNumber} + + ) : null} + {subStationName ? ( + + {subStationName} + + ) : null} + + + + + + + {eta.value.length ? ( + + {eta.label.length ? ( + + {eta.label} + + ) : null} + + + {eta.value} + + {eta.unit.length ? ( + + {eta.unit} + + ) : null} + + + ) : null} + + {transferText.length ? ( + + + + {translate( + isJapaneseState ? 'transferShort' : 'transferShortEn' + )} + + + {transferText} + + + ) : null} + + + + ); +}; + +export default React.memo(HeaderLowPower); diff --git a/src/components/LineBoard.tsx b/src/components/LineBoard.tsx index fca18508f9..babaf5154e 100644 --- a/src/components/LineBoard.tsx +++ b/src/components/LineBoard.tsx @@ -13,6 +13,7 @@ import LineBoardEast from './LineBoardEast'; import LineBoardJO from './LineBoardJO'; import LineBoardJRKyushu from './LineBoardJRKyushu'; import LineBoardLED from './LineBoardLED'; +import LineBoardLowPower from './LineBoardLowPower'; import LineBoardSaikyo from './LineBoardSaikyo'; import LineBoardToei from './LineBoardToei'; import LineBoardWest from './LineBoardWest'; @@ -133,6 +134,8 @@ const LineBoard: React.FC = ({ hasTerminus = false }: Props) => { ); case APP_THEME.LED: return ; + case APP_THEME.LOW_POWER: + return ; case APP_THEME.JO: case APP_THEME.JL: return ( diff --git a/src/components/LineBoardLowPower.test.tsx b/src/components/LineBoardLowPower.test.tsx new file mode 100644 index 0000000000..21a4ef1d9a --- /dev/null +++ b/src/components/LineBoardLowPower.test.tsx @@ -0,0 +1,238 @@ +import { render } from '@testing-library/react-native'; +import type { Station } from '~/@types/graphql'; +import { StopCondition } from '~/@types/graphql'; +import LineBoardLowPower from './LineBoardLowPower'; + +jest.mock('jotai', () => ({ + ...jest.requireActual('jotai'), + useAtomValue: jest.fn(), +})); + +const NO_INSETS = { top: 0, right: 0, bottom: 0, left: 0 }; + +jest.mock('~/hooks', () => ({ + useLowPowerLayout: jest.fn(() => ({ + width: 720, + height: 360, + scale: 1, + insets: { top: 0, right: 0, bottom: 0, left: 0 }, + })), + useDisplayCurrentStation: jest.fn(), + useEstimateArrivalTimes: jest.fn(() => ({ route: null })), + useEstimatedMinutesByStationId: jest.fn(() => new Map()), + useTransferLinesFromStation: jest.fn(() => []), +})); + +const { useAtomValue } = require('jotai'); +const { + useDisplayCurrentStation, + useEstimatedMinutesByStationId, + useLowPowerLayout, + useTransferLinesFromStation, +} = require('~/hooks'); +const { headerStateAtom } = require('~/store/atoms/navigation'); +const { arrivedAtom } = require('~/store/atoms/station'); +const { isEnAtom } = require('~/store/selectors/isEn'); + +const makeStation = (id: number, name: string, nameRoman: string): Station => + ({ + id, + groupId: id * 10, + name, + nameRoman, + stopCondition: StopCondition.All, + }) as unknown as Station; + +const STATIONS = [ + makeStation(1, '都立大学', 'Toritsu-daigaku'), + makeStation(2, '自由が丘', 'Jiyugaoka'), + makeStation(3, '田園調布', 'Den-en-chofu'), + makeStation(4, '多摩川', 'Tamagawa'), +]; + +const setAtomValues = ({ + arrived = false, + headerState = 'NEXT', + isEn = false, +}: { + arrived?: boolean; + headerState?: string; + isEn?: boolean; +} = {}) => { + useAtomValue.mockImplementation((atom: unknown) => { + if (atom === arrivedAtom) return arrived; + if (atom === headerStateAtom) return headerState; + if (atom === isEnAtom) return isEn; + return undefined; + }); +}; + +const markerLeft = (element: { props: { style?: unknown } }) => { + const style = element.props.style as { left?: string }; + return style.left; +}; + +describe('LineBoardLowPower', () => { + beforeEach(() => { + setAtomValues(); + useLowPowerLayout.mockReturnValue({ + width: 720, + height: 360, + scale: 1, + insets: NO_INSETS, + }); + useDisplayCurrentStation.mockReturnValue(STATIONS[0]); + useEstimatedMinutesByStationId.mockReturnValue(new Map()); + useTransferLinesFromStation.mockReturnValue([]); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('渡された駅をすべて水平に並べる', () => { + const { getByText } = render(); + + for (const station of STATIONS) { + expect(getByText(station.name as string)).toBeTruthy(); + } + }); + + it('英語表示ではローマ字の駅名に切り替わる', () => { + setAtomValues({ isEn: true }); + const { getByText, queryByText } = render( + + ); + + expect(getByText('Jiyugaoka')).toBeTruthy(); + expect(queryByText('自由が丘')).toBeNull(); + }); + + it('到着分は取得できた駅だけに出し、先頭列は単位の見出しにする', () => { + useEstimatedMinutesByStationId.mockReturnValue( + new Map([ + [2, 2], + [3, 4.4], + ]) + ); + const { getByText, queryByText } = render( + + ); + + expect(getByText('分')).toBeTruthy(); + expect(getByText('2')).toBeTruthy(); + // 小数は四捨五入して整数で出す + expect(getByText('4')).toBeTruthy(); + expect(queryByText('4.4')).toBeNull(); + }); + + it('停車中は列車位置を現在駅の真上に置く', () => { + setAtomValues({ arrived: true }); + const { getByTestId } = render(); + + // 4列なので1列あたり25%、先頭列の中心は12.5% + expect(markerLeft(getByTestId('low-power-line-board-marker'))).toBe( + '12.5%' + ); + }); + + it('接近中は列車位置を次駅寄りへ動かす', () => { + const runningView = render(); + const running = markerLeft( + runningView.getByTestId('low-power-line-board-marker') + ); + + // React.memo が効くので、状態を変えたら別インスタンスとして描画し直す + setAtomValues({ headerState: 'ARRIVING' }); + const arrivingView = render(); + const arriving = markerLeft( + arrivingView.getByTestId('low-power-line-board-marker') + ); + + expect(Number.parseFloat(running as string)).toBeGreaterThan(12.5); + expect(Number.parseFloat(arriving as string)).toBeGreaterThan( + Number.parseFloat(running as string) + ); + }); + + it('乗換路線の記号を上限まで並べ、あふれた分は「+N」に畳む', () => { + useTransferLinesFromStation.mockReturnValue([ + { lineSymbols: [{ symbol: 'JY' }] }, + { lineSymbols: [{ symbol: 'G' }] }, + { lineSymbols: [{ symbol: 'Z' }] }, + { lineSymbols: [] }, + ]); + const { getAllByText } = render( + + ); + + expect(getAllByText('JY')).toHaveLength(1); + expect(getAllByText('G')).toHaveLength(1); + expect(getAllByText('+2')).toHaveLength(1); + }); + + it('groupId が重複していても stationId で現在駅を特定する', () => { + // 6の字運転では同じ駅が groupId を共有したまま複数回現れる。 + // groupId で引くと最初の出現を掴んでしまい、列車位置・現在駅マーク・ + // 通過済み表示がすべて別の位置に出る + const loop = [ + { ...makeStation(101, '都庁前', 'Tochomae'), groupId: 999 }, + { ...makeStation(102, '新宿西口', 'Shinjuku-nishiguchi'), groupId: 500 }, + { ...makeStation(103, '都庁前', 'Tochomae'), groupId: 999 }, + { ...makeStation(104, '光が丘', 'Hikarigaoka'), groupId: 501 }, + ] as unknown as Station[]; + setAtomValues({ arrived: true }); + useDisplayCurrentStation.mockReturnValue(loop[2]); + + const { getByTestId } = render(); + + // 4列なので1列あたり25%。3列目(index 2)の中心は62.5% + expect(markerLeft(getByTestId('low-power-line-board-marker'))).toBe( + '62.5%' + ); + }); + + it('stationId が一致しないときは groupId へ落とす', () => { + // useDisplayCurrentStation は stations とは別経路の駅を返すことがあり、 + // id で引けない。その場合に先頭駅へ黙って落ちないことを担保する + setAtomValues({ arrived: true }); + useDisplayCurrentStation.mockReturnValue({ + ...STATIONS[2], + id: 9999, + } as unknown as Station); + + const { getByTestId } = render(); + + expect(markerLeft(getByTestId('low-power-line-board-marker'))).toBe( + '62.5%' + ); + }); + + it('駅が空でも落ちない', () => { + expect(() => render()).not.toThrow(); + }); + + it('セーフエリアぶんを下端と左右の余白として確保する', () => { + // 上端が非ゼロの端末を想定する。ストリップの上端は画面上端ではなく + // ヘッダーの直下なので、insets.top を足さないことも併せて確かめる + useLowPowerLayout.mockReturnValue({ + width: 660, + height: 326, + scale: 326 / 360, + insets: { top: 34, right: 21, bottom: 21, left: 39 }, + }); + + const { getByTestId } = render(); + + const scale = 326 / 360; + expect(getByTestId('low-power-line-board-root').props.style).toEqual( + expect.objectContaining({ + // 上端のセーフエリアはヘッダーが引き受けるため、ここでは足さない + paddingTop: 6 * scale, + paddingBottom: 4 * scale + 21, + paddingLeft: 16 * scale + 39, + paddingRight: 16 * scale + 21, + }) + ); + }); +}); diff --git a/src/components/LineBoardLowPower.tsx b/src/components/LineBoardLowPower.tsx new file mode 100644 index 0000000000..8967f0756f --- /dev/null +++ b/src/components/LineBoardLowPower.tsx @@ -0,0 +1,347 @@ +import { useAtomValue } from 'jotai'; +import React, { useMemo } from 'react'; +import { View } from 'react-native'; +import type { Station } from '~/@types/graphql'; +import { + useDisplayCurrentStation, + useEstimateArrivalTimes, + useEstimatedMinutesByStationId, + useLowPowerLayout, + useTransferLinesFromStation, +} from '~/hooks'; +import { isEnAtom } from '~/store/selectors/isEn'; +import { FONTS, LOW_POWER_THEME_COLORS } from '../constants'; +import { headerStateAtom } from '../store/atoms/navigation'; +import { arrivedAtom } from '../store/atoms/station'; +import getIsPass from '../utils/isPass'; +import Typography from './Typography'; + +const { background, primary, secondary, muted, accent } = + LOW_POWER_THEME_COLORS; + +/** 乗換路線記号を並べる上限。あふれた分は「+N」に畳む */ +const MAX_TRANSFER_SYMBOLS = 2; + +type Metrics = { + scale: number; + hairline: number; + markerRowHeight: number; + markRowHeight: number; + etaRowHeight: number; + nameRowHeight: number; + chipRowHeight: number; + markSize: number; +}; + +type ColumnProps = { + station: Station; + metrics: Metrics; + isEn: boolean; + /** 列車が今いる駅 */ + isCurrent: boolean; + /** 次に停まる駅 */ + isNext: boolean; + /** 現在位置より手前(通過済み) */ + isBehind: boolean; + /** 到着まで何分か。現在駅や取得できない駅では null */ + minutes: number | null; + /** 到着分の単位。先頭列だけ見出しとして単位を出す */ + unitLabel: string; +}; + +const StationColumn: React.FC = ({ + station, + metrics, + isEn, + isCurrent, + isNext, + isBehind, + minutes, + unitLabel, +}) => { + const transferLines = useTransferLinesFromStation(station, { omitJR: true }); + const { scale, hairline, markSize } = metrics; + + // 停車しない駅はマークを小さく描いて、停まる駅と形で見分けられるようにする + const isPassStation = getIsPass(station); + + const mark = useMemo(() => { + if (isCurrent) { + return { + size: markSize, + backgroundColor: accent, + borderColor: accent, + borderWidth: 2 * scale, + }; + } + if (isNext) { + return { + size: markSize, + backgroundColor: background, + borderColor: primary, + borderWidth: 3 * scale, + }; + } + if (isBehind) { + return { + size: markSize, + backgroundColor: muted, + borderColor: muted, + borderWidth: 2 * scale, + }; + } + return { + size: isPassStation ? markSize * 0.6 : markSize, + backgroundColor: background, + borderColor: muted, + borderWidth: (isPassStation ? 1 : 2) * scale, + }; + }, [isBehind, isCurrent, isNext, isPassStation, markSize, scale]); + + const nameColor = useMemo(() => { + if (isBehind) { + return muted; + } + if (isCurrent || isNext) { + return primary; + } + return isPassStation ? muted : secondary; + }, [isBehind, isCurrent, isNext, isPassStation]); + + const chips = useMemo(() => { + if (!transferLines.length) { + return []; + } + const symbols = transferLines + .map((line) => line.lineSymbols?.[0]?.symbol) + .filter((symbol): symbol is string => !!symbol) + .slice(0, MAX_TRANSFER_SYMBOLS); + const rest = transferLines.length - symbols.length; + return rest > 0 ? [...symbols, `+${rest}`] : symbols; + }, [transferLines]); + + const etaText = minutes != null ? String(Math.round(minutes)) : unitLabel; + + return ( + + + + + + + {etaText} + + + {(isEn ? station.nameRoman : station.name) ?? ''} + + + {chips.map((chip) => ( + + {chip} + + ))} + + + ); +}; + +export type Props = { + stations: Station[]; +}; + +/** + * 低消費電力テーマ(#3697)の停車駅ストリップ。 + * + * 既存テーマの斜め書き(-55°)は低解像度でいちばん潰れるため使わず、駅名を + * 水平に置いて可読性を優先する。点滅シェブロンの代わりに、列車位置は + * 動かない三角形ひとつで示す。 + */ +const LineBoardLowPower: React.FC = ({ stations }: Props) => { + // ヘッダーと同じ実効領域から拡大率を起こし、外周にセーフエリアぶんの余白を足す + const { scale, insets } = useLowPowerLayout(); + const arrived = useAtomValue(arrivedAtom); + const headerState = useAtomValue(headerStateAtom); + const isEn = useAtomValue(isEnAtom); + const currentStation = useDisplayCurrentStation(); + const { route: estimatedRoute } = useEstimateArrivalTimes(); + const estimatedMinutesByStationId = + useEstimatedMinutesByStationId(estimatedRoute); + + const metrics = useMemo( + () => ({ + scale, + hairline: Math.max(1, scale), + markerRowHeight: 16 * scale, + markRowHeight: 16 * scale, + etaRowHeight: 16 * scale, + nameRowHeight: 40 * scale, + chipRowHeight: 18 * scale, + markSize: 14 * scale, + }), + [scale] + ); + + // 6の字運転(大江戸線の都庁前など)では、同じ駅が groupId を共有したまま + // 進行方向ごとに別エントリとして現れる。stationId は出現ごとに一意なので + // 先にそちらで引き、引けないときだけ groupId へ落とす。この順序は + // useDisplayCurrentStation の findIndexByIdentity と揃えてある。 + const currentStationId = currentStation?.id; + const currentStationGroupId = currentStation?.groupId; + const currentIndex = useMemo(() => { + if (currentStationId != null) { + const byId = stations.findIndex((s) => s.id === currentStationId); + if (byId !== -1) { + return byId; + } + } + if (currentStationGroupId != null) { + const byGroupId = stations.findIndex( + (s) => s.groupId === currentStationGroupId + ); + if (byGroupId !== -1) { + return byGroupId; + } + } + return 0; + }, [currentStationGroupId, currentStationId, stations]); + + // 停車中は現在駅の真上、走行中は次駅寄りへ寄せる。接近中はさらに次駅へ近づける + const markerLeft = useMemo<`${number}%`>(() => { + if (!stations.length) { + return '0%'; + } + const segment = 100 / stations.length; + const center = segment * (currentIndex + 0.5); + if (arrived) { + return `${center}%`; + } + const progress = headerState.startsWith('ARRIVING') ? 0.88 : 0.45; + return `${Math.min(center + segment * progress, 100)}%`; + }, [arrived, currentIndex, headerState, stations.length]); + + if (!stations.length) { + return ; + } + + const unitLabel = isEn ? 'min.' : '分'; + + return ( + + + + + + {stations.map((station, index) => ( + + ))} + + + + ); +}; + +export default React.memo(LineBoardLowPower); diff --git a/src/components/NewFeatureDot.tsx b/src/components/NewFeatureDot.tsx new file mode 100644 index 0000000000..f84dbd9dbb --- /dev/null +++ b/src/components/NewFeatureDot.tsx @@ -0,0 +1,107 @@ +import type React from 'react'; +import { memo, useEffect } from 'react'; +import { StyleSheet, View } from 'react-native'; +import Animated, { + cancelAnimation, + Easing, + useAnimatedStyle, + useReducedMotion, + useSharedValue, + withRepeat, + withTiming, +} from 'react-native-reanimated'; + +type Props = { + color: string; + /** ドットの直径。既定は設定リスト用の 12px */ + size?: number; + /** リングを広げるか。フッタータブなど道しるべ側は静止させる */ + pulse?: boolean; +}; + +export const NEW_FEATURE_DOT_SIZE = 12; +/** フッタータブのアイコンに載せる方は小さくする */ +export const NEW_FEATURE_DOT_SIZE_SMALL = 8; + +const PULSE_DURATION = 2400; +// 画面が出ている間は止めない。数周期で止めると、設定を開いてスクロールしている +// 間にパルスが終わってしまい、以降はただの点になって印の役目を果たさない。 +// この印は外観画面を一度開くか機能をオンにした時点で出なくなるので、 +// 出続けること自体が催促にはならない。 +const PULSE_REPEAT = -1; +const RING_MAX_SCALE = 2.8; +const RING_START_OPACITY = 0.55; +// 1周期のうちリングが広がりきるまでの割合。残りは次の波までの休み +const RING_ACTIVE_RATIO = 0.7; + +const styles = StyleSheet.create({ + root: { + alignItems: 'center', + justifyContent: 'center', + }, + ring: { + ...StyleSheet.absoluteFill, + }, +}); + +/** + * 新機能の在り処を示す印。地の色を足さずに視線だけ拾うため、 + * アクセント色のドットからリングが広がって消える表現にしている。 + * OS の「アニメーションを減らす」が有効なときは静止ドットになる。 + */ +const NewFeatureDot: React.FC = ({ + color, + size = NEW_FEATURE_DOT_SIZE, + pulse = true, +}: Props) => { + const progress = useSharedValue(0); + const reducedMotion = useReducedMotion(); + const animated = pulse && !reducedMotion; + + useEffect(() => { + if (!animated) { + return; + } + progress.value = 0; + progress.value = withRepeat( + withTiming(1, { + duration: PULSE_DURATION, + easing: Easing.out(Easing.ease), + }), + PULSE_REPEAT, + false + ); + return () => { + cancelAnimation(progress); + }; + }, [animated, progress]); + + const ringStyle = useAnimatedStyle(() => { + const eased = Math.min(progress.value / RING_ACTIVE_RATIO, 1); + return { + transform: [{ scale: 1 + (RING_MAX_SCALE - 1) * eased }], + opacity: RING_START_OPACITY * (1 - eased), + }; + }); + + const dotStyle = { + width: size, + height: size, + borderRadius: size / 2, + backgroundColor: color, + }; + + return ( + + {animated ? ( + + ) : null} + + + ); +}; + +export default memo(NewFeatureDot); diff --git a/src/components/NumberingIconNankai.test.tsx b/src/components/NumberingIconNankai.test.tsx index 368486bf9d..180a8ab1a3 100644 --- a/src/components/NumberingIconNankai.test.tsx +++ b/src/components/NumberingIconNankai.test.tsx @@ -2,6 +2,9 @@ import { render } from '@testing-library/react-native'; import { NUMBERING_ICON_SIZE } from '~/constants'; import NumberingIconNankai from './NumberingIconNankai'; +// isTablet=false 時のアイコン外形サイズ +const ICON_SIZE = 72; + jest.mock('~/utils/isTablet', () => ({ __esModule: true, default: false, @@ -55,6 +58,65 @@ describe('NumberingIconNankai', () => { expect(getByText('01')).toBeTruthy(); }); + it.each([ + ['withOutlineなし', undefined, 1], + ['withOutlineあり', true, 2], + ])( + '%s のとき楕円のフチが描画領域からはみ出さない', + (_label, withOutline, expectedStrokeWidth) => { + const { getByTestId } = render( + + ); + const ellipse = getByTestId('ellipse'); + const { cx, cy, rx, ry, strokeWidth } = ellipse.props; + + expect(strokeWidth).toBe(expectedStrokeWidth); + // フチはパスの中心から左右に strokeWidth / 2 ずつ広がる + expect(cx - rx - strokeWidth / 2).toBeGreaterThanOrEqual(0); + expect(cx + rx + strokeWidth / 2).toBeLessThanOrEqual(ICON_SIZE); + expect(cy - ry - strokeWidth / 2).toBeGreaterThanOrEqual(0); + expect(cy + ry + strokeWidth / 2).toBeLessThanOrEqual(ICON_SIZE); + } + ); + + it('withOutlineの有無でアイコンの占有サイズが変わらない', () => { + const withoutOutline = render( + + ).getByTestId('ellipse'); + const withOutline = render( + + ).getByTestId('ellipse'); + + type EllipseProps = { rx: number; ry: number; strokeWidth: number }; + const outerWidth = (props: EllipseProps) => + props.rx * 2 + props.strokeWidth; + const outerHeight = (props: EllipseProps) => + props.ry * 2 + props.strokeWidth; + + expect(outerWidth(withOutline.props as EllipseProps)).toBe( + outerWidth(withoutOutline.props as EllipseProps) + ); + expect(outerHeight(withOutline.props as EllipseProps)).toBe( + outerHeight(withoutOutline.props as EllipseProps) + ); + }); + + it('記号と番号が折り返されない', () => { + const { getByText } = render( + + ); + expect(getByText('NK').props.numberOfLines).toBe(1); + expect(getByText('01').props.numberOfLines).toBe(1); + }); + it('stationNumberが正しく分割される', () => { const { getByText } = render( diff --git a/src/components/NumberingIconNankai.tsx b/src/components/NumberingIconNankai.tsx index dd9651e9ef..df59461df5 100644 --- a/src/components/NumberingIconNankai.tsx +++ b/src/components/NumberingIconNankai.tsx @@ -9,6 +9,11 @@ import { import isTablet from '../utils/isTablet'; import Typography from './Typography'; +const ICON_SIZE = isTablet ? 72 * 1.5 : 72; +// 楕円の白フチ。withOutline のときは同じフチを太らせて代用する +const STROKE_WIDTH = 1; +const OUTLINE_STROKE_WIDTH = isTablet ? 3 : 2; + type Props = { stationNumber: string; lineColor: string; @@ -17,18 +22,21 @@ type Props = { }; const styles = StyleSheet.create({ - optionalBorder: { - borderRadius: (isTablet ? 72 * 1.5 : 72) / 2, - borderWidth: 2, - borderColor: '#fff', - }, root: { position: 'relative', + width: ICON_SIZE, + height: ICON_SIZE, justifyContent: 'center', alignItems: 'center', }, + // インセットを指定しない絶対配置だと文字の折り返し幅が楕円の幅として解決されず、 + // 記号が途中で改行されてしまうため SVG と同じ矩形をぴったり覆わせる texts: { position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, justifyContent: 'center', alignItems: 'center', }, @@ -85,24 +93,33 @@ const NumberingIconNankai: React.FC = ({ ); } + // 白フチは View のボーダーで囲うと真円になり楕円のシンボルと形が合わないため、 + // 楕円自身のストロークを太らせて表現する + const strokeWidth = withOutline ? OUTLINE_STROKE_WIDTH : STROKE_WIDTH; + // ストロークはパスの中心から外側にも半分伸びるので、その分だけ半径を詰めないと + // 楕円の左右端でフチが SVG の描画領域からはみ出して欠ける + const strokeInset = strokeWidth / 2; + return ( - - - - - - - {lineSymbol} - {stationNumber} - + + + + + + + {lineSymbol} + + + {stationNumber} + ); diff --git a/src/components/NumberingIconReversedSquareWest.tsx b/src/components/NumberingIconReversedSquareWest.tsx index 9576a5e363..a1aa4832be 100644 --- a/src/components/NumberingIconReversedSquareWest.tsx +++ b/src/components/NumberingIconReversedSquareWest.tsx @@ -2,6 +2,7 @@ import type React from 'react'; import { Platform, StyleSheet, View } from 'react-native'; import { FONTS } from '../constants'; import isTablet from '../utils/isTablet'; +import { numberingGlyphLift } from '../utils/numberingGlyphLift'; import Typography from './Typography'; type Props = { @@ -12,6 +13,9 @@ type Props = { withOutline?: boolean; }; +// Androidのグリフ下寄り補正。記号と番号で同じ値を使わないと両者の間隔が変わる +const GLYPH_LIFT = numberingGlyphLift(isTablet ? 30 * 1.5 : 30); + const styles = StyleSheet.create({ optionalBorder: { borderWidth: 2, @@ -28,6 +32,7 @@ const styles = StyleSheet.create({ lineSymbol: { fontSize: isTablet ? 30 * 1.5 : 30, lineHeight: isTablet ? 30 * 1.5 : 30, + transform: GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.FrutigerNeueLTProBold, marginTop: Platform.OS === 'ios' ? 4 : 0, @@ -36,6 +41,7 @@ const styles = StyleSheet.create({ marginTop: -4, fontSize: isTablet ? 30 * 1.5 : 30, lineHeight: isTablet ? 30 * 1.5 : 30, + transform: GLYPH_LIFT, textAlign: 'center', fontFamily: FONTS.FrutigerNeueLTProBold, }, diff --git a/src/components/NumberingIconSquare.tsx b/src/components/NumberingIconSquare.tsx index 91031fb230..f76930361b 100644 --- a/src/components/NumberingIconSquare.tsx +++ b/src/components/NumberingIconSquare.tsx @@ -6,6 +6,7 @@ import { type NumberingIconSize, } from '../constants'; import isTablet from '../utils/isTablet'; +import { numberingGlyphLift } from '../utils/numberingGlyphLift'; import Typography from './Typography'; type Props = { @@ -20,6 +21,16 @@ type Props = { const TLC_SCALE = 0.7; +// Androidのグリフ下寄り補正。記号と番号で異なる値を使うと両者の間隔まで変わるため、 +// アイコンごとに基準の行高から1つだけ求めて使い回す +const GLYPH_LIFT = numberingGlyphLift(isTablet ? 24 * 1.5 : 24); +const TLC_GLYPH_LIFT = numberingGlyphLift( + isTablet ? Math.round(24 * 1.5 * TLC_SCALE) : Math.round(24 * TLC_SCALE) +); +const TLC_COMPACT_GLYPH_LIFT = numberingGlyphLift(isTablet ? 12 : 8); +const MEDIUM_GLYPH_LIFT = numberingGlyphLift(isTablet ? 32 : 20); +const SMALL_GLYPH_LIFT = numberingGlyphLift(10); + const styles = StyleSheet.create({ optionalBorder: { borderRadius: 8, @@ -68,14 +79,7 @@ const styles = StyleSheet.create({ lineHeight: isTablet ? Math.round(24 * 1.5 * TLC_SCALE) : Math.round(24 * TLC_SCALE), - // Androidはグリフが行ボックス下寄りに描画されTLCが下がって見えるため - // 上方向に補正して上下中央に揃える(iOSは補正不要)。 - // 負マージンだと下のアイコンごと動いてバッジ全体が縮むため、 - // レイアウトに影響しないtransformで文字だけを持ち上げる - transform: - Platform.OS === 'android' - ? [{ translateY: Math.round(-6 * TLC_SCALE) }] - : [], + transform: TLC_GLYPH_LIFT, }, tlcIconRoot: { width: isTablet @@ -99,6 +103,7 @@ const styles = StyleSheet.create({ lineHeight: isTablet ? Math.round(24 * 1.5 * TLC_SCALE) : Math.round(24 * TLC_SCALE), + transform: TLC_GLYPH_LIFT, fontSize: isTablet ? Math.round(24 * 1.5 * TLC_SCALE) : Math.round(24 * TLC_SCALE), @@ -111,6 +116,7 @@ const styles = StyleSheet.create({ lineHeight: isTablet ? Math.round(32 * 1.5 * TLC_SCALE) : Math.round(32 * TLC_SCALE), + transform: TLC_GLYPH_LIFT, fontSize: isTablet ? Math.round(32 * 1.5 * TLC_SCALE) : Math.round(32 * TLC_SCALE), @@ -129,6 +135,7 @@ const styles = StyleSheet.create({ paddingHorizontal: isTablet ? 2 : 1, }, tlcTextCompact: { + transform: TLC_COMPACT_GLYPH_LIFT, color: 'white', textAlign: 'center', fontSize: isTablet ? 12 : 8, @@ -148,6 +155,7 @@ const styles = StyleSheet.create({ }, tlcLineSymbolCompact: { lineHeight: isTablet ? 12 : 8, + transform: TLC_COMPACT_GLYPH_LIFT, fontSize: isTablet ? 12 : 8, textAlign: 'center', fontFamily: FONTS.FrutigerNeueLTProBold, @@ -156,6 +164,7 @@ const styles = StyleSheet.create({ }, tlcStationNumberCompact: { lineHeight: isTablet ? 15 : 10, + transform: TLC_COMPACT_GLYPH_LIFT, fontSize: isTablet ? 15 : 10, marginTop: isTablet ? -2 : -1, textAlign: 'center', @@ -174,6 +183,7 @@ const styles = StyleSheet.create({ }, lineSymbolMedium: { lineHeight: isTablet ? 32 : 20, + transform: MEDIUM_GLYPH_LIFT, fontSize: isTablet ? 32 : 20, textAlign: 'center', fontFamily: FONTS.FrutigerNeueLTProBold, @@ -195,14 +205,17 @@ const styles = StyleSheet.create({ }, lineSymbolSmall: { fontSize: 10, + transform: SMALL_GLYPH_LIFT, lineHeight: 10, textAlign: 'center', fontFamily: FONTS.FrutigerNeueLTProBold, - marginTop: 2, + // 他のサイズと同様、視覚補正の marginTop は iOS のみ + marginTop: Platform.OS === 'ios' ? 2 : 0, color: '#231e1f', }, lineSymbol: { lineHeight: isTablet ? 24 * 1.5 : 24, + transform: GLYPH_LIFT, fontSize: isTablet ? 24 * 1.5 : 24, textAlign: 'center', fontFamily: FONTS.FrutigerNeueLTProBold, @@ -211,6 +224,7 @@ const styles = StyleSheet.create({ }, stationNumber: { lineHeight: isTablet ? 32 * 1.5 : 32, + transform: GLYPH_LIFT, fontSize: isTablet ? 32 * 1.5 : 32, marginTop: -4, textAlign: 'center', diff --git a/src/components/Permitted.tsx b/src/components/Permitted.tsx index 2770f3466e..a34cf4d746 100644 --- a/src/components/Permitted.tsx +++ b/src/components/Permitted.tsx @@ -15,7 +15,7 @@ import { Linking, Platform, StyleSheet, View } from 'react-native'; import { LongPressGestureHandler, State } from 'react-native-gesture-handler'; import Share from 'react-native-share'; import ViewShot, { type ViewShotRef } from 'react-native-view-shot'; -import { overlayAppColorsAtom } from '~/store/atoms/colorScheme'; +import { resolvedAppColorsAtom } from '~/store/atoms/colorScheme'; import reportModalVisibleAtom from '~/store/atoms/reportModal'; import tuningState from '~/store/atoms/tuning'; import { getActionSheetColorOptions } from '~/utils/actionSheetColors'; @@ -46,7 +46,7 @@ import { import { useTrainTypeModal } from '../hooks/useTrainTypeModal'; import { storage } from '../lib/storage'; import { THEME_PREFERENCE, type ThemePreference } from '../models/Theme'; -import { portraitModeEnabledAtom } from '../store/atoms/experimental'; +import { portraitModeEnabledAtom } from '../store/atoms/display'; import navigationState, { autoModeEnabledAtom, isAppLatestAtom, @@ -139,7 +139,7 @@ const PermittedLayout: React.FC = ({ children }: Props) => { // アクションシートは車内再現(走行画面)とは別レイヤーの一時的なUIなので、 // 走行画面から開いた場合も配色設定に追従させる。走行画面はProviderの // 外側にあるため、モーダル本体と同じくatomを直接購読する。 - const actionSheetColors = useAtomValue(overlayAppColorsAtom); + const actionSheetColors = useAtomValue(resolvedAppColorsAtom); const { sendReport, descriptionLowerLimit } = useFeedback(user); const { warningInfo, clearWarningInfo } = useWarningInfo(); const { @@ -264,7 +264,17 @@ const PermittedLayout: React.FC = ({ children }: Props) => { try { const capturedURI = await viewShotCapture(); const file = new File(capturedURI); - const base64 = await file.base64(); + // ViewShot が書き出した一時ファイルは base64 へ読み出した時点で用済み。 + // 消さないとフルスクリーン画像がキャッシュ領域に溜まり続ける。 + // 読み出しに失敗した場合も残さないよう finally で消す + let base64: string; + try { + base64 = await file.base64(); + } finally { + try { + file.delete(); + } catch {} + } setScreenShotBase64(base64); setReportModalShow(true); } catch (err) { @@ -289,8 +299,19 @@ const PermittedLayout: React.FC = ({ children }: Props) => { try { const capturedURI = await viewShotCapture(); const file = new File(capturedURI); - const base64 = await file.base64(); - const urlString = `data:image/jpeg;base64,${base64}`; + // 読み出し済みの一時ファイルを残さない(フィードバック送信側と同様)。 + // 読み出しに失敗した場合も残さないよう finally で消す + let base64: string; + try { + base64 = await file.base64(); + } finally { + try { + file.delete(); + } catch {} + } + // ViewShot の出力は PNG (options.format)。下の type と合わせて image/png で統一する。 + // 以前は jpeg を名乗っており、実データと MIME が食い違っていた + const urlString = `data:image/png;base64,${base64}`; const message = isJapanese ? `${currentLine.nameShort?.replace( diff --git a/src/components/PortraitMain.tablet.test.tsx b/src/components/PortraitMain.tablet.test.tsx new file mode 100644 index 0000000000..bd6f0bb9cb --- /dev/null +++ b/src/components/PortraitMain.tablet.test.tsx @@ -0,0 +1,132 @@ +import { render } from '@testing-library/react-native'; +import { createStore, Provider } from 'jotai'; +import { StyleSheet } from 'react-native'; +import { type Line, type Station, StopCondition } from '~/@types/graphql'; +import { useCurrentStation, useHeaderCommonData } from '~/hooks'; +import { COLOR_SCHEME_PREFERENCE } from '~/models/ColorScheme'; +import { colorSchemePreferenceAtom } from '~/store/atoms/colorScheme'; +import { bottomStateAtom } from '~/store/atoms/navigation'; +import { + arrivedAtom, + selectedDirectionAtom, + stationsAtom, +} from '~/store/atoms/station'; +import PortraitMain from './PortraitMain'; + +jest.mock('~/translation', () => ({ + isJapanese: true, + translate: jest.fn((key: string) => key), +})); + +jest.mock('~/utils/isTablet', () => ({ + __esModule: true, + default: true, +})); + +// タブレットはノッチがないぶん上端のセーフエリアが 0 になる +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: jest.fn(() => ({ + top: 0, + right: 0, + bottom: 20, + left: 0, + })), +})); + +jest.mock('./NumberingIcon', () => () => null); + +jest.mock('~/hooks', () => ({ + useBoundText: jest.fn(() => ({ + JA: '品川・大崎方面', + EN: 'for Shinagawa & Osaki', + })), + useCurrentLine: jest.fn(() => ({ + id: 11302, + color: '#80C241', + nameShort: '山手線', + nameRoman: 'Yamanote Line', + })), + useCurrentStation: jest.fn(), + useCurrentTrainType: jest.fn(() => null), + useEstimateArrivalTimesAllStops: jest.fn(() => ({ + route: null, + loading: false, + error: null, + })), + useEstimatedMinutesByStationId: jest.fn(() => new Map()), + useGetLineMark: jest.fn(() => () => null), + useHeaderCommonData: jest.fn(), + useLoopLine: jest.fn(() => ({ isLoopLine: false })), + useStationNumberIndexFunc: jest.fn(() => () => 0), + useTransferLines: jest.fn(() => []), + useTransferLinesFromStation: jest.fn(() => []), + useTransferStationNumbers: jest.fn((lines: Line[]) => lines.map(() => null)), + useTransferTargetStation: jest.fn(() => undefined), +})); + +const station = { + id: 1, + groupId: 1, + name: '品川', + nameRoman: 'Shinagawa', + stopCondition: StopCondition.All, + stationNumbers: [{ stationNumber: 'JY-25' }], + line: { + id: 11302, + color: '#80C241', + nameShort: '山手線', + nameRoman: 'Yamanote Line', + }, + lines: [], +} as unknown as Station; + +describe('PortraitMain - tablet', () => { + beforeEach(() => { + (useHeaderCommonData as jest.Mock).mockReturnValue({ + stateText: 'nextKana', + stationText: '高輪ゲートウェイ', + boundText: '品川・大崎方面', + currentStationNumber: { + lineSymbol: 'JY', + lineSymbolColor: '#80C241', + lineSymbolShape: 'ROUND', + stationNumber: 'JY-26', + }, + threeLetterCode: undefined, + numberingColor: '#80C241', + headerState: 'NEXT_KANA', + }); + (useCurrentStation as jest.Mock).mockReturnValue(station); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('セーフエリア上端が 0 でも下端と同じだけの余白を上端に確保する', () => { + const store = createStore(); + store.set(colorSchemePreferenceAtom, COLOR_SCHEME_PREFERENCE.LIGHT); + store.set(stationsAtom, [station]); + store.set(selectedDirectionAtom, 'INBOUND'); + store.set(arrivedAtom, true); + store.set(bottomStateAtom, 'LINE'); + + const { getByTestId } = render( + + + + ); + + const paddingTop = StyleSheet.flatten( + getByTestId('portrait-root').props.style + ).paddingTop; + const paddingBottom = StyleSheet.flatten( + getByTestId('portrait-stop-list').props.contentContainerStyle + ).paddingBottom; + + // 下端: リストの下パディング 12 + セーフエリア下端 20 + expect(paddingBottom).toBe(12 + 20); + // 上端も同じ量を敷き、路線情報が画面の縁に貼り付かないようにする + expect(paddingTop).toBe(paddingBottom); + }); +}); diff --git a/src/components/PortraitMain.test.tsx b/src/components/PortraitMain.test.tsx index 7770f33d82..325aec6e98 100644 --- a/src/components/PortraitMain.test.tsx +++ b/src/components/PortraitMain.test.tsx @@ -1,25 +1,37 @@ import { fireEvent, render, within } from '@testing-library/react-native'; import { createStore, Provider } from 'jotai'; +import { getLuminance } from 'polished'; import { StyleSheet } from 'react-native'; -import { type Station, StopCondition } from '~/@types/graphql'; +import { type Line, type Station, StopCondition } from '~/@types/graphql'; +import { DARK_APP_COLORS, LIGHT_APP_COLORS } from '~/constants/colorScheme'; import { useCurrentLine, useCurrentStation, useCurrentTrainType, + useEstimatedMinutesByStationId, useHeaderCommonData, + useLoopLine, + useTransferLines, useTransferLinesFromStation, + useTransferTargetStation, } from '~/hooks'; +import { COLOR_SCHEME_PREFERENCE } from '~/models/ColorScheme'; +import { THEME_PREFERENCE, type ThemePreference } from '~/models/Theme'; +import { colorSchemePreferenceAtom } from '~/store/atoms/colorScheme'; +import { bottomStateAtom } from '~/store/atoms/navigation'; import { arrivedAtom, selectedDirectionAtom, stationsAtom, } from '~/store/atoms/station'; +import { themePreferenceAtom } from '~/store/atoms/theme'; +import { translate } from '~/translation'; import { RFValue } from '~/utils/rfValue'; import PortraitMain from './PortraitMain'; jest.mock('~/translation', () => ({ isJapanese: true, - translate: (key: string) => key, + translate: jest.fn((key: string) => key), })); jest.mock('react-native-safe-area-context', () => ({ @@ -31,24 +43,47 @@ jest.mock('react-native-safe-area-context', () => ({ })), })); -jest.mock('./NumberingIcon', () => () => null); +const mockNumberingIcon = jest.fn(); +jest.mock('./NumberingIcon', () => (props: { lineColor: string }) => { + mockNumberingIcon(props); + return null; +}); jest.mock('~/hooks', () => ({ - useBoundText: jest.fn(() => ({ JA: '品川・大崎方面' })), + useBoundText: jest.fn(() => ({ + JA: '品川・大崎方面', + EN: 'for Shinagawa & Osaki', + })), useCurrentLine: jest.fn(), useCurrentStation: jest.fn(), useCurrentTrainType: jest.fn(), + useEstimateArrivalTimesAllStops: jest.fn(() => ({ + route: null, + loading: false, + error: null, + })), + useEstimatedMinutesByStationId: jest.fn(() => new Map()), + useGetLineMark: jest.fn(() => () => null), useHeaderCommonData: jest.fn(), + useLoopLine: jest.fn(() => ({ isLoopLine: false })), useStationNumberIndexFunc: jest.fn(() => () => 0), + useTransferLines: jest.fn(() => []), useTransferLinesFromStation: jest.fn(() => []), + useTransferStationNumbers: jest.fn((lines: Line[]) => lines.map(() => null)), + useTransferTargetStation: jest.fn(() => undefined), })); const mockedUseHeaderCommonData = useHeaderCommonData as jest.Mock; const mockedUseCurrentLine = useCurrentLine as jest.Mock; const mockedUseCurrentStation = useCurrentStation as jest.Mock; const mockedUseCurrentTrainType = useCurrentTrainType as jest.Mock; +const mockedUseLoopLine = useLoopLine as unknown as jest.Mock; const mockedUseTransferLinesFromStation = useTransferLinesFromStation as jest.Mock; +const mockedUseTransferLines = useTransferLines as jest.Mock; +const mockedUseTransferTargetStation = useTransferTargetStation as jest.Mock; +const mockedUseEstimatedMinutesByStationId = + useEstimatedMinutesByStationId as jest.Mock; const yamanoteLine = { id: 11302, @@ -69,6 +104,7 @@ const commonData = { }, threeLetterCode: undefined, numberingColor: '#80C241', + headerState: 'NEXT_KANA', }; const buildStation = ( @@ -94,18 +130,42 @@ const renderWithStations = ( { arrived = true, currentStation = stations[0], - }: { arrived?: boolean; currentStation?: Station } = {} + colorScheme = COLOR_SCHEME_PREFERENCE.LIGHT, + themePreference, + bottomState = 'LINE' as const, + direction = 'INBOUND' as const, + transferStation, + onPress, + onTransferPress, + }: { + arrived?: boolean; + currentStation?: Station; + colorScheme?: (typeof COLOR_SCHEME_PREFERENCE)[keyof typeof COLOR_SCHEME_PREFERENCE]; + themePreference?: ThemePreference; + bottomState?: 'LINE' | 'TRANSFER' | 'TYPE_CHANGE'; + direction?: 'INBOUND' | 'OUTBOUND'; + transferStation?: Station; + onPress?: () => void; + onTransferPress?: (station?: Station) => void; + } = {} ) => { const store = createStore(); - // 全駅表示。INBOUND は反転しないので渡した順がそのまま表示順になる。 + // 端末のダークモード状態に左右されないよう、配色は常に明示して固定する + store.set(colorSchemePreferenceAtom, colorScheme); + if (themePreference) { + store.set(themePreferenceAtom, themePreference); + } + // 全駅表示。非環状線の INBOUND は反転しないので渡した順がそのまま表示順になる。 store.set(stationsAtom, stations); - store.set(selectedDirectionAtom, 'INBOUND'); + store.set(selectedDirectionAtom, direction); store.set(arrivedAtom, arrived); + store.set(bottomStateAtom, bottomState); mockedUseCurrentStation.mockReturnValue(currentStation); + mockedUseTransferTargetStation.mockReturnValue(transferStation); return render( - + ); }; @@ -119,7 +179,13 @@ describe('PortraitMain', () => { nameRoman: 'Local', color: '#123456', }); + mockedUseLoopLine.mockReturnValue({ isLoopLine: false }); mockedUseTransferLinesFromStation.mockReturnValue([]); + mockedUseTransferLines.mockReturnValue([]); + mockedUseTransferTargetStation.mockReturnValue(undefined); + mockedUseEstimatedMinutesByStationId.mockReturnValue( + new Map() + ); }); afterEach(() => { @@ -141,6 +207,26 @@ describe('PortraitMain', () => { ); }); + it('英語環境では行き先を英語表記で表示する', () => { + // isJapanese はモジュールスコープの定数なので、モック済みモジュールの + // プロパティを差し替えて英語環境を再現する + const translationMock = jest.requireMock('~/translation') as { + isJapanese: boolean; + }; + translationMock.isJapanese = false; + + try { + const { getByText, queryByText } = renderWithStations([ + buildStation(1, '品川', StopCondition.All, 'JY-25'), + ]); + + expect(getByText('for Shinagawa & Osaki')).toBeTruthy(); + expect(queryByText('品川・大崎方面')).toBeNull(); + } finally { + translationMock.isJapanese = true; + } + }); + it('駅名がスロットに収まるときは末尾欠け防止のバッファ分だけ余白を取り横圧縮しない', () => { const { getByTestId } = renderWithStations([ buildStation(1, '品川', StopCondition.All, 'JY-25'), @@ -186,7 +272,7 @@ describe('PortraitMain', () => { }); it('停車駅リストに通過駅も含めて駅名とナンバリングを表示する', () => { - const { getByText, queryByText } = renderWithStations([ + const { getByText } = renderWithStations([ buildStation(1, '品川', StopCondition.All, 'JY-25'), buildStation(2, '新橋', StopCondition.Not, 'JY-26'), buildStation(3, '田町', StopCondition.All, 'JY-27'), @@ -197,8 +283,8 @@ describe('PortraitMain', () => { expect(getByText('田町')).toBeTruthy(); expect(getByText('JY-25')).toBeTruthy(); expect(getByText('JY-27')).toBeTruthy(); - // 「通過」ラベルは表示しない - expect(queryByText('passStationLabel')).toBeNull(); + // 通過駅であることは行内の「通過」ラベルでも示す + expect(getByText('portraitPassLabel')).toBeTruthy(); }); it('発車後は先頭駅の行が半透明になり強調が次の停車駅へ移る', () => { @@ -220,10 +306,10 @@ describe('PortraitMain', () => { ).toBe(yamanoteLine.color); // 強調(フォント拡大)は発車済みの品川ではなく次の停車駅の田町に付く expect(StyleSheet.flatten(getByText('田町').props.style).fontSize).toBe( - RFValue(18) + RFValue(15) ); expect(StyleSheet.flatten(getByText('品川').props.style).fontSize).toBe( - RFValue(16) + RFValue(14) ); // 列車位置の三角は現在駅と次駅の間(次駅行の上側セグメント)に出る expect( @@ -247,9 +333,9 @@ describe('PortraitMain', () => { StyleSheet.flatten(getByTestId('stop-row-1').props.style).opacity ).toBeUndefined(); expect(StyleSheet.flatten(getByText('品川').props.style).fontSize).toBe( - RFValue(18) + RFValue(15) ); - // 列車位置の三角は現在駅の行に出る + // 列車位置のピンは現在駅の行に出る expect( within(getByTestId('stop-row-1')).getByTestId('train-chevron') ).toBeTruthy(); @@ -277,6 +363,56 @@ describe('PortraitMain', () => { ).toBe(yamanoteLine.color); }); + it('環状線のOUTBOUNDは駅順が進行方向なので反転しない', () => { + mockedUseLoopLine.mockReturnValue({ isLoopLine: true }); + + const { getByTestId } = renderWithStations( + [ + buildStation(1, '品川', StopCondition.All, 'JY-25'), + buildStation(2, '田町', StopCondition.All, 'JY-27'), + buildStation(3, '浜松町', StopCondition.All, 'JY-28'), + ], + { + arrived: true, + currentStation: buildStation(2, '田町', StopCondition.All), + direction: 'OUTBOUND', + } + ); + + // 進行方向は index 増加方向。現在駅より手前の品川が淡色、先の浜松町は通常色 + expect( + StyleSheet.flatten(getByTestId('stop-dot-1').props.style).borderColor + ).not.toBe(yamanoteLine.color); + expect( + StyleSheet.flatten(getByTestId('stop-dot-3').props.style).borderColor + ).toBe(yamanoteLine.color); + }); + + it('環状線のINBOUNDは駅順と進行方向が逆なので反転する', () => { + mockedUseLoopLine.mockReturnValue({ isLoopLine: true }); + + const { getByTestId } = renderWithStations( + [ + buildStation(1, '品川', StopCondition.All, 'JY-25'), + buildStation(2, '田町', StopCondition.All, 'JY-27'), + buildStation(3, '浜松町', StopCondition.All, 'JY-28'), + ], + { + arrived: true, + currentStation: buildStation(2, '田町', StopCondition.All), + direction: 'INBOUND', + } + ); + + // 進行方向は index 減少方向。現在駅より手前の浜松町が淡色、先の品川は通常色 + expect( + StyleSheet.flatten(getByTestId('stop-dot-3').props.style).borderColor + ).not.toBe(yamanoteLine.color); + expect( + StyleSheet.flatten(getByTestId('stop-dot-1').props.style).borderColor + ).toBe(yamanoteLine.color); + }); + it('直通先の駅は直通先のラインカラーで描画される', () => { const keihinTohokuLine = { id: 11332, @@ -310,6 +446,17 @@ describe('PortraitMain', () => { ).toBe(8); }); + it('ナンバリングには配色スキームで加工していない路線色をそのまま渡す', () => { + renderWithStations([buildStation(1, '品川', StopCondition.All, 'JY-25')], { + colorScheme: COLOR_SCHEME_PREFERENCE.DARK, + }); + + // ダークでも明度を持ち上げず、API 由来の路線色をそのまま描く + expect(mockNumberingIcon).toHaveBeenCalledWith( + expect.objectContaining({ lineColor: commonData.numberingColor }) + ); + }); + it('現在駅にナンバリングがないときは枠を確保せず駅名表示に充てる', () => { mockedUseHeaderCommonData.mockReturnValue({ ...commonData, @@ -398,4 +545,547 @@ describe('PortraitMain', () => { expect(queryByText('山手線')).toBeNull(); expect(queryByText('品川')).toBeNull(); }); + it('通過駅の行は停車駅の行より低く、同じ画面高でより多くの駅を見せる', () => { + const { getByTestId } = renderWithStations([ + buildStation(1, '品川', StopCondition.All, 'JY-25'), + buildStation(2, '新橋', StopCondition.Not, 'JY-26'), + ]); + + const stopHeight = StyleSheet.flatten(getByTestId('stop-row-1').props.style) + .minHeight as number; + const passHeight = StyleSheet.flatten(getByTestId('stop-row-2').props.style) + .minHeight as number; + expect(passHeight).toBeLessThan(stopHeight); + }); + + it('ライト設定では地・カード・本文にライトのトークンを使う', () => { + const { getByTestId } = renderWithStations([ + buildStation(1, '品川', StopCondition.All, 'JY-25'), + ]); + + expect( + StyleSheet.flatten(getByTestId('portrait-root').props.style) + .backgroundColor + ).toBe(LIGHT_APP_COLORS.background); + expect( + StyleSheet.flatten(getByTestId('portrait-station-card').props.style) + .backgroundColor + ).toBe(LIGHT_APP_COLORS.card); + expect( + StyleSheet.flatten(getByTestId('portrait-station-name').props.style).color + ).toBe(LIGHT_APP_COLORS.text); + }); + + it('ダーク設定では地・カード・本文がダークのトークンへ切り替わる', () => { + const { getByTestId } = renderWithStations( + [buildStation(1, '品川', StopCondition.All, 'JY-25')], + { colorScheme: COLOR_SCHEME_PREFERENCE.DARK } + ); + + expect( + StyleSheet.flatten(getByTestId('portrait-root').props.style) + .backgroundColor + ).toBe(DARK_APP_COLORS.background); + expect( + StyleSheet.flatten(getByTestId('portrait-station-card').props.style) + .backgroundColor + ).toBe(DARK_APP_COLORS.card); + expect( + StyleSheet.flatten(getByTestId('portrait-station-name').props.style).color + ).toBe(DARK_APP_COLORS.text); + }); + + // ポートレートは路線テーマに依存しないレイアウトで電光掲示板風の配色を持たないため、 + // 電光掲示板風テーマ選択中でも配色設定のダークがそのまま効く + it('電光掲示板風テーマ選択中でもダーク設定ならダークのトークンを使う', () => { + const { getByTestId } = renderWithStations( + [buildStation(1, '品川', StopCondition.All, 'JY-25')], + { + colorScheme: COLOR_SCHEME_PREFERENCE.DARK, + themePreference: THEME_PREFERENCE.LED, + } + ); + + expect( + StyleSheet.flatten(getByTestId('portrait-root').props.style) + .backgroundColor + ).toBe(DARK_APP_COLORS.background); + expect( + StyleSheet.flatten(getByTestId('portrait-station-card').props.style) + .backgroundColor + ).toBe(DARK_APP_COLORS.card); + expect( + StyleSheet.flatten(getByTestId('portrait-station-name').props.style).color + ).toBe(DARK_APP_COLORS.text); + }); + + it('ダークでは沈まないよう路線色の明度を上げた色で線路を描く', () => { + const { getByTestId } = renderWithStations( + [buildStation(1, '品川', StopCondition.All, 'JY-25')], + { colorScheme: COLOR_SCHEME_PREFERENCE.DARK } + ); + + const trackColor = StyleSheet.flatten( + getByTestId('track-bottom-1').props.style + ).backgroundColor as string; + + expect(trackColor).not.toBe(yamanoteLine.color); + // 暗い地から浮くよう、元の路線色より明るい色になっている + expect(getLuminance(trackColor)).toBeGreaterThan( + getLuminance(yamanoteLine.color) + ); + }); + + it('進捗バーは走行→接近→停車の順に伸び、停車で満ちる', () => { + const widthFor = (headerState: string) => { + mockedUseHeaderCommonData.mockReturnValue({ ...commonData, headerState }); + const { getByTestId, unmount } = renderWithStations([ + buildStation(1, '品川', StopCondition.All, 'JY-25'), + ]); + const width = StyleSheet.flatten( + getByTestId('portrait-progress-fill').props.style + ).width as string; + unmount(); + return Number.parseFloat(width); + }; + + const next = widthFor('NEXT_KANA'); + const arriving = widthFor('ARRIVING_KANA'); + const current = widthFor('CURRENT_KANA'); + + expect(next).toBeLessThan(arriving); + expect(arriving).toBeLessThan(current); + expect(current).toBe(100); + }); + + it('停車中はカードの脇に起点の駅と次の停車駅を出す。次がなければ出さない', () => { + const { getByText } = renderWithStations([ + buildStation(1, '品川', StopCondition.All, 'JY-25'), + buildStation(2, '新橋', StopCondition.Not, 'JY-26'), + buildStation(3, '田町', StopCondition.All, 'JY-27'), + ]); + // 通過駅の新橋は飛ばして田町が次の停車駅になる。どの駅を起点にした「つぎ」 + // なのかが読み取れるよう、現在駅(品川)も添えて出す。 + expect(getByText('portraitNextStopFrom')).toBeTruthy(); + expect(translate).toHaveBeenCalledWith('portraitNextStopFrom', { + current: '品川', + station: '田町', + }); + + const { queryByTestId } = renderWithStations([ + buildStation(1, '品川', StopCondition.All, 'JY-25'), + ]); + expect(queryByTestId('portrait-card-meta')).toBeNull(); + }); + + it('通過駅を最寄りにしている間はその駅を通過中として出す', () => { + const { getByText } = renderWithStations( + [ + buildStation(1, '品川', StopCondition.All, 'JY-25'), + buildStation(2, '新橋', StopCondition.Not, 'JY-26'), + buildStation(3, '田町', StopCondition.All, 'JY-27'), + ], + { + arrived: false, + currentStation: buildStation(2, '新橋', StopCondition.Not, 'JY-26'), + } + ); + + // カードは次の停車駅(田町)を出しているので、通過駅の名前はここにしか出ない + expect(getByText('portraitPassThrough')).toBeTruthy(); + expect(translate).toHaveBeenCalledWith('portraitPassThrough', { + station: '新橋', + }); + }); + + it('最終駅を発車済み扱いのまま留まってもピンが消えず全行が淡色にならない', () => { + // arrived が false の間 useRefreshStation は現在駅を進めないため、終点に着いた + // あと到着判定が外れると「最終駅にいて未到着」という状態が続く。素直に次駅へ + // 進めるとピンが範囲外へ出て、全行が発車済みの淡色になってしまう。 + const { getByTestId } = renderWithStations( + [ + buildStation(1, '品川', StopCondition.All, 'JY-25'), + buildStation(2, '田町', StopCondition.All, 'JY-27'), + ], + { + arrived: false, + currentStation: buildStation(2, '田町', StopCondition.All), + } + ); + + // 列車ピンは最終駅の行に残る + expect( + within(getByTestId('stop-row-2')).getByTestId('train-chevron') + ).toBeTruthy(); + // 最終駅の行は発車済みの淡色にしない + expect( + StyleSheet.flatten(getByTestId('stop-body-2').props.style).opacity + ).toBeUndefined(); + }); + + it('停車駅を発車して次の停車駅へ向かっている間は注記を出さない', () => { + // まだ通過していない駅を「通過中」と予告してしまわないよう、最寄りが停車駅の + // 間は先の通過駅(新橋)には触れない。 + const { queryByTestId } = renderWithStations( + [ + buildStation(1, '品川', StopCondition.All, 'JY-25'), + buildStation(2, '新橋', StopCondition.Not, 'JY-26'), + buildStation(3, '田町', StopCondition.All, 'JY-27'), + ], + { arrived: false } + ); + + expect(queryByTestId('portrait-card-meta')).toBeNull(); + }); + + describe('各駅のETA', () => { + // 品川(現在駅) → 新橋(通過) → 田町 → 浜松町 + const etaStations = () => [ + buildStation(1, '品川', StopCondition.All, 'JY-25'), + buildStation(2, '新橋', StopCondition.Not, 'JY-26'), + buildStation(3, '田町', StopCondition.All, 'JY-27'), + buildStation(4, '浜松町', StopCondition.All, 'JY-28'), + ]; + + it('ETAのある停車駅に残り分と単位を出す', () => { + mockedUseEstimatedMinutesByStationId.mockReturnValue( + new Map([ + [3, 4], + [4, 11], + ]) + ); + + const { getByTestId } = renderWithStations(etaStations()); + + expect(within(getByTestId('stop-eta-3')).getByText('4')).toBeTruthy(); + expect(within(getByTestId('stop-eta-4')).getByText('11')).toBeTruthy(); + // 単位は全行に添える + expect( + within(getByTestId('stop-eta-3')).getByText('portraitEtaUnit') + ).toBeTruthy(); + expect( + within(getByTestId('stop-eta-4')).getByText('portraitEtaUnit') + ).toBeTruthy(); + }); + + it('小数のETAは分に丸めて出す', () => { + mockedUseEstimatedMinutesByStationId.mockReturnValue(new Map([[3, 4.6]])); + + const { getByTestId } = renderWithStations(etaStations()); + + expect(within(getByTestId('stop-eta-3')).getByText('5')).toBeTruthy(); + }); + + it('丸めて0分になる駅は数字ではなく「まもなく」を出す', () => { + // 0分と出すと「もう着いた」と読めてしまうため + mockedUseEstimatedMinutesByStationId.mockReturnValue(new Map([[3, 0.4]])); + + const { getByTestId } = renderWithStations(etaStations()); + + expect( + within(getByTestId('stop-eta-3')).getByText('portraitEtaSoon') + ).toBeTruthy(); + expect( + within(getByTestId('stop-eta-3')).queryByText('portraitEtaUnit') + ).toBeNull(); + }); + + it('ETAが取れている路線では、値の無い停車駅にもプレースホルダを出して桁位置を揃える', () => { + mockedUseEstimatedMinutesByStationId.mockReturnValue(new Map([[3, 4]])); + + const { getByTestId } = renderWithStations(etaStations()); + + expect(within(getByTestId('stop-eta-4')).getByText('--')).toBeTruthy(); + }); + + it('通過駅にはETAを出さない', () => { + mockedUseEstimatedMinutesByStationId.mockReturnValue(new Map([[3, 4]])); + + const { queryByTestId } = renderWithStations(etaStations()); + + expect(queryByTestId('stop-eta-2')).toBeNull(); + }); + + it('停車中の駅と発車済みの駅には列を出さない', () => { + // これらの駅は相対値が0以下になり変換側で落ちるため、列を出すと + // 「--」だけが並ぶ。品川を発車済みなので品川・新橋には出さない。 + mockedUseEstimatedMinutesByStationId.mockReturnValue( + new Map([ + [3, 4], + [4, 11], + ]) + ); + + const { queryByTestId } = renderWithStations(etaStations(), { + arrived: false, + }); + + expect(queryByTestId('stop-eta-1')).toBeNull(); + expect(queryByTestId('stop-eta-3')).toBeTruthy(); + }); + + it('ETAの値がすべて null のときは列ごと出さない', () => { + // stops は揃っていても cumulativeMinutes が全部 null の応答がある。 + // 件数だけで判定すると「--」だけの列が出てしまう。 + mockedUseEstimatedMinutesByStationId.mockReturnValue( + new Map([ + [3, null], + [4, null], + ]) + ); + + const { queryByTestId } = renderWithStations(etaStations()); + + expect(queryByTestId('stop-eta-3')).toBeNull(); + expect(queryByTestId('stop-eta-4')).toBeNull(); + }); + + it('ETAが1駅も取れないときは列ごと出さない', () => { + // 全行に「--」が並び続けるより、右端を今までどおり空けておく + mockedUseEstimatedMinutesByStationId.mockReturnValue( + new Map() + ); + + const { queryByTestId } = renderWithStations(etaStations()); + + expect(queryByTestId('stop-eta-3')).toBeNull(); + expect(queryByTestId('stop-eta-4')).toBeNull(); + }); + + it('次の停車駅のETAだけ路線色で一回り大きく出す', () => { + mockedUseEstimatedMinutesByStationId.mockReturnValue( + new Map([ + [3, 4], + [4, 11], + ]) + ); + + // 品川を発車済みなので、次の停車駅は通過駅の新橋を挟んだ田町になる + const { getByTestId } = renderWithStations(etaStations(), { + arrived: false, + }); + + const focused = StyleSheet.flatten( + within(getByTestId('stop-eta-3')).getByText('4').props.style + ); + expect(focused.fontSize).toBe(RFValue(15)); + expect(focused.color).toBe('#80C241'); + + const rest = StyleSheet.flatten( + within(getByTestId('stop-eta-4')).getByText('11').props.style + ); + expect(rest.fontSize).toBe(RFValue(13)); + expect(rest.color).toBe(LIGHT_APP_COLORS.secondaryText); + }); + }); + + describe('のりかえ案内', () => { + const shinjuku = buildStation(100, '新宿', StopCondition.All, 'JC-05'); + const shinsenShinjuku = buildStation(200, '新線新宿', StopCondition.All); + + const buildTransferLine = ( + id: number, + nameShort: string, + color: string, + station: Station + ): Line => + ({ + id, + nameShort, + nameRoman: `${nameShort}-roman`, + color, + lineSymbols: [], + station, + }) as unknown as Line; + + const yamanote = buildTransferLine(11302, '山手線', '#80C241', shinjuku); + const keioNew = buildTransferLine( + 99310, + '京王新線', + '#CA0073', + shinsenShinjuku + ); + + it('下部の表示が TRANSFER のときは停車駅リストに重ねてのりかえ案内を出す', () => { + mockedUseTransferLines.mockReturnValue([yamanote]); + + const { getByTestId, getByText } = renderWithStations([shinjuku], { + bottomState: 'TRANSFER', + transferStation: shinjuku, + }); + + expect(getByTestId('portrait-transfers')).toBeTruthy(); + // 見出しは translate('transfer') をそのまま使う + expect(getByText('transfer')).toBeTruthy(); + // メタ行にも運転中の路線名が出るので、行に絞って確かめる + expect( + within(getByTestId('portrait-transfer-row-11302')).getByText('山手線') + ).toBeTruthy(); + // 案内対象の駅は見出しの脇に出す + expect(getByTestId('portrait-transfer-station').props.children).toBe( + '新宿駅' + ); + // リストは外さずに重ねるだけなので、下のスクロール位置は保たれる + expect(getByTestId('portrait-stop-list')).toBeTruthy(); + }); + + it('乗換路線が無いときは TRANSFER でものりかえ案内を出さない', () => { + mockedUseTransferLines.mockReturnValue([]); + + const { queryByTestId } = renderWithStations([shinjuku], { + bottomState: 'TRANSFER', + }); + + expect(queryByTestId('portrait-transfers')).toBeNull(); + }); + + it('乗換先が案内中の駅と同じなら駅名を添えず、別の駅のときだけ添える', () => { + mockedUseTransferLines.mockReturnValue([yamanote, keioNew]); + + const { getByTestId, getAllByText } = renderWithStations([shinjuku], { + bottomState: 'TRANSFER', + transferStation: shinjuku, + }); + + // 同じ新宿駅なので、山手線の行には駅名を出さない + expect( + within(getByTestId('portrait-transfer-row-11302')).queryByText('新宿駅') + ).toBeNull(); + // 新線新宿は別の駅なので添える + expect( + within(getByTestId('portrait-transfer-row-99310')).getByText( + '新線新宿駅' + ) + ).toBeTruthy(); + // 「新宿駅」は見出しの脇だけ。行に同じ駅名が重ねて出ていないこと + expect(getAllByText('新宿駅')).toHaveLength(1); + }); + + it('上部の路線情報・カードのタップで下部の表示を進める', () => { + const onPress = jest.fn(); + const { getByTestId } = renderWithStations([shinjuku], { onPress }); + + fireEvent.press(getByTestId('portrait-header-tap')); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('停車駅リストのタップでも下部の表示を進める', () => { + const onPress = jest.fn(); + const { getByTestId } = renderWithStations([shinjuku], { onPress }); + + fireEvent.press(getByTestId('portrait-stop-list-tap')); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('のりかえ一覧の余白タップは駅なしで渡す(横画面と同じく表示が進む)', () => { + mockedUseTransferLines.mockReturnValue([yamanote]); + const onTransferPress = jest.fn(); + const { getByTestId } = renderWithStations([shinjuku], { + bottomState: 'TRANSFER', + transferStation: shinjuku, + onTransferPress, + }); + + fireEvent.press(getByTestId('portrait-transfer-list-tap')); + + expect(onTransferPress).toHaveBeenCalledTimes(1); + expect(onTransferPress).toHaveBeenCalledWith(undefined); + }); + + it('のりかえ行タップでは路線と駅を渡し、表示は進めない', () => { + mockedUseTransferLines.mockReturnValue([yamanote]); + const onPress = jest.fn(); + const onTransferPress = jest.fn(); + + const { getByTestId } = renderWithStations([shinjuku], { + bottomState: 'TRANSFER', + transferStation: shinjuku, + onPress, + onTransferPress, + }); + + fireEvent.press(getByTestId('portrait-transfer-row-11302')); + + expect(onTransferPress).toHaveBeenCalledTimes(1); + expect(onTransferPress.mock.calls[0][0]).toMatchObject({ + groupId: shinjuku.groupId, + line: yamanote, + }); + expect(onPress).not.toHaveBeenCalled(); + }); + + it('路線と路線の間に余白を取る', () => { + mockedUseTransferLines.mockReturnValue([yamanote, keioNew]); + + const { getByTestId } = renderWithStations([shinjuku], { + bottomState: 'TRANSFER', + transferStation: shinjuku, + }); + + // 行の直接の親はタップ領域の Pressable。ScrollView の + // contentContainerStyle に置いても行間には入らない。 + const style = StyleSheet.flatten( + getByTestId('portrait-transfer-list-tap').props.style + ); + expect(style.rowGap).toBeGreaterThan(0); + }); + + it('のりかえ一覧をスクロールした指では表示を進めない', () => { + mockedUseTransferLines.mockReturnValue([yamanote]); + const onTransferPress = jest.fn(); + + const { getByTestId } = renderWithStations([shinjuku], { + bottomState: 'TRANSFER', + transferStation: shinjuku, + onTransferPress, + }); + + fireEvent(getByTestId('portrait-transfer-list'), 'scrollBeginDrag'); + fireEvent.press(getByTestId('portrait-transfer-list-tap')); + + expect(onTransferPress).not.toHaveBeenCalled(); + }); + + it('のりかえ一覧をスクロールした指では路線変更へ渡さない', () => { + mockedUseTransferLines.mockReturnValue([yamanote]); + const onTransferPress = jest.fn(); + + const { getByTestId } = renderWithStations([shinjuku], { + bottomState: 'TRANSFER', + transferStation: shinjuku, + onTransferPress, + }); + + fireEvent(getByTestId('portrait-transfer-list'), 'scrollBeginDrag'); + fireEvent.press(getByTestId('portrait-transfer-row-11302')); + + expect(onTransferPress).not.toHaveBeenCalled(); + }); + + it('停車駅リストをスクロールした指でも表示を進めない', () => { + const onPress = jest.fn(); + + const { getByTestId } = renderWithStations([shinjuku], { onPress }); + + fireEvent(getByTestId('portrait-stop-list'), 'scrollBeginDrag'); + fireEvent.press(getByTestId('portrait-stop-list-tap')); + + expect(onPress).not.toHaveBeenCalled(); + }); + + it('指を置き直せばスクロール後でもタップは効く', () => { + const onPress = jest.fn(); + + const { getByTestId } = renderWithStations([shinjuku], { onPress }); + + fireEvent(getByTestId('portrait-stop-list'), 'scrollBeginDrag'); + // 指を離して置き直したところからは、また普通のタップとして扱う + fireEvent(getByTestId('portrait-root'), 'touchStart'); + fireEvent.press(getByTestId('portrait-stop-list-tap')); + + expect(onPress).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/components/PortraitMain.tsx b/src/components/PortraitMain.tsx index 0a623ec718..d48aa4a4d6 100644 --- a/src/components/PortraitMain.tsx +++ b/src/components/PortraitMain.tsx @@ -1,14 +1,31 @@ import { LinearGradient } from 'expo-linear-gradient'; import { useAtomValue } from 'jotai'; -import { darken, getLuminance, mix, rgba } from 'polished'; +import { + darken, + getLuminance, + mix, + parseToHsl, + rgba, + setLightness, +} from 'polished'; import type React from 'react'; -import { memo, useEffect, useMemo, useRef, useState } from 'react'; +import { + memo, + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react'; import { type LayoutChangeEvent, type NativeSyntheticEvent, + Pressable, ScrollView, StyleSheet, type TextLayoutEventData, + TouchableOpacity, View, } from 'react-native'; import Animated, { @@ -20,18 +37,39 @@ import Animated, { withTiming, } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { Circle, Path, Svg } from 'react-native-svg'; -import type { Station } from '~/@types/graphql'; -import { parenthesisRegexp } from '~/constants'; +import { + Circle, + Defs, + Stop as GradientStop, + Path, + RadialGradient, + Rect, + Svg, +} from 'react-native-svg'; +import type { Line, Station } from '~/@types/graphql'; +import { NUMBERING_ICON_SIZE, parenthesisRegexp } from '~/constants'; +import type { AppColors } from '~/constants/colorScheme'; import { useBoundText, useCurrentLine, useCurrentStation, useCurrentTrainType, + useEstimateArrivalTimesAllStops, + useEstimatedMinutesByStationId, + useGetLineMark, useHeaderCommonData, + useLoopLine, useStationNumberIndexFunc, + useTransferLines, useTransferLinesFromStation, + useTransferStationNumbers, + useTransferTargetStation, } from '~/hooks'; +import { resolvedAppColorsAtom } from '~/store/atoms/colorScheme'; +import { + bottomStateAtom, + enabledLanguagesAtom, +} from '~/store/atoms/navigation'; import { arrivedAtom, selectedDirectionAtom, @@ -47,39 +85,64 @@ import { normalizeTrainTypeColor, } from '~/utils/trainTypeTextColor'; import NumberingIcon from './NumberingIcon'; +import TransferLineDot from './TransferLineDot'; +import TransferLineMark from './TransferLineMark'; import Typography from './Typography'; -// テーマ非依存の独自カラーパレット。選択中のテーマに関わらず、設定画面などの -// 操作系画面と印象を揃えたライト基調(白ベース)で統一する。 -const COLORS = { - background: '#FFFFFF', - textPrimary: '#212121', - textSecondary: '#8B8B8B', - // 通過駅の駅名・記号用。停車駅(textSecondary)よりさらに薄くして - // 「停まらない駅」であることを色の淡さで示す。 - textPass: '#C2C2C2', - divider: '#E0E0E0', - fallbackAccent: '#888888', -} as const; - -// 白背景の上に文字色として置いても読めるよう、明るい路線色は暗めに倒す。 +// 走行画面は AppColorsProvider の外側で描画されるため useAppColors() は常に +// ライトの値を返す。ポートレートは配色設定に追従させたいので atom を直接読む。 +// appColorsAtom は電光掲示板風テーマ選択中にライトを返すが、この画面は路線テーマに +// 依存しないレイアウトで電光掲示板風の配色を持たないため、そちらではなく +// 上書きを受けない resolvedAppColorsAtom を読む。 +const FALLBACK_ACCENT = '#888888'; + +// 通過駅の駅名・記号用。停車駅(secondaryText)よりさらに弱くして +// 「停まらない駅」であることを色の淡さで示す。ダークは既存トークンを流用する。 +const PASS_TEXT_LIGHT = '#C2C2C2'; +const passTextColor = (colors: AppColors): string => + colors.isDark ? colors.strongBorder : PASS_TEXT_LIGHT; + +// 白基調の地の上に文字色として置いても読めるよう、明るい路線色は暗めに倒す。 +const LIGHT_ACCENT_MAX_LUMINANCE = 0.5; + +// 暗い地の上では逆に路線色が沈むため、HSL の明度に下限を設けて起こす。 +// 輝度ではなく明度で測って明度を直すのは、変換の前後が同じ尺度で読めるため。 +const DARK_ACCENT_MIN_LIGHTNESS = 0.62; + const readableAccentColor = (color: string): string => { try { - return getLuminance(color) > 0.5 ? darken(0.25, color) : color; + return getLuminance(color) > LIGHT_ACCENT_MAX_LUMINANCE + ? darken(0.25, color) + : color; + } catch { + return FALLBACK_ACCENT; + } +}; + +const luminousAccentColor = (color: string): string => { + try { + const { lightness } = parseToHsl(color); + return lightness >= DARK_ACCENT_MIN_LIGHTNESS + ? color + : setLightness(DARK_ACCENT_MIN_LIGHTNESS, color); } catch { - return COLORS.fallbackAccent; + return FALLBACK_ACCENT; } }; -// 発車済み駅のトラック(縦棒・ドット)用に白へ寄せた淡い色。 -// opacity で半透明フェードすると縦棒とリングの重なり部分だけ二重合成で -// 濃くなって不自然なので、不透明のままフェードした色を使い、同色の -// 重なりが目立たないようにする。 -const departedTrackColor = (color: string): string => { +/** 配色スキームに合わせて地の上で読める路線色に直す */ +export const accentColorFor = (color: string, isDark: boolean): string => + isDark ? luminousAccentColor(color) : readableAccentColor(color); + +// 発車済み区間の縦棒・ドット用に地へ寄せた色。opacity で半透明フェードすると +// 縦棒とリングの重なり部分だけ二重合成で濃くなって不自然なので、不透明のまま +// フェードした色を使う。路線色を残したまま薄めるので直通先の識別も保たれる。 +const DEPARTED_TRACK_MIX = 0.4; +const departedTrackColor = (accent: string, background: string): string => { try { - return mix(0.4, color, COLORS.background); + return mix(DEPARTED_TRACK_MIX, accent, background); } catch { - return COLORS.divider; + return background; } }; @@ -102,118 +165,179 @@ const resolveStateText = (stateText: string, headerState: string): string => { } }; -// 駅名セクションの背景に敷く路線色の淡いティント。 -const lineTintColor = (color: string): string => { - try { - // 明るい路線色はそのまま透過するとほぼ白に潰れて - // 視認できないため、文字色と同じ補正を通してから透過する - return rgba(readableAccentColor(color), 0.08); - } catch { - return COLORS.background; +// 駅間の進み具合。実際の距離ではなくヘッダーの遷移状態から3段階で出す。 +// 「次は」→ 出たばかり、「まもなく」→ 接近、「ただいま停車中」→ 到着で満ちる。 +const PROGRESS_NEXT = 0.3; +const PROGRESS_ARRIVING = 0.72; +const PROGRESS_CURRENT = 1; +const progressForState = (headerState: string): number => { + if (headerState.startsWith('CURRENT')) { + return PROGRESS_CURRENT; + } + if (headerState.startsWith('ARRIVING')) { + return PROGRESS_ARRIVING; } + return PROGRESS_NEXT; }; -// 画面端からコンテンツまでの左右余白。区切り線は全幅のまま、 -// 路線カラーバー・駅名・停車駅リストをこの分だけ内側に寄せる。 +// 画面端からリストまでの左右余白。カードはこれより内側に置いて地から浮かせる。 const CONTENT_INSET = 24; +const CARD_INSET = 20; // NumberingIcon の LARGE サイズ実寸(NumberingIconRound 基準)に合わせた固定幅。 // ナンバリングがある駅では駅名の長さで記号の表示位置が動かないよう // 駅名行の左端に固定幅の枠を確保する。ナンバリングがない駅では枠ごと // 描画せず、その分を駅名表示に充てる。行の minHeight にも流用し、 -// ナンバリングの有無で駅名セクションの高さが変わらないようにする。 +// ナンバリングの有無でカードの高さが変わらないようにする。 const NUMBERING_COLUMN_WIDTH = isTablet ? 72 * 1.5 : 72; // 日本語グリフでネイティブのテキスト計測幅がわずかに過小評価されると、 // 表示用 Typography に計測幅ぴったりの width を与えたとき末尾の文字が // 欠ける。HeaderStationName と同じく計測幅にこの分を加えて余白を確保する。 -// iOS ではフォントメトリクスの誤差が大きめに出ることがあるため余裕を持たせる。 const STATION_NAME_MEASURE_BUFFER = 16; // 駅名の自然幅を測る非表示コンテナの幅。どんなに長い駅名でも打ち切られない // よう十分大きく取る。フォールバック計測でこの値以上なら未確定とみなす。 const STATION_NAME_MEASURE_WIDTH = 10000; -// トラック列と縦棒の幅。一筋の光(trackLight)を縦棒の x 位置・幅に合わせる -// ためにも使う。 -const TRACK_COLUMN_WIDTH = 36; -const TRACK_LINE_WIDTH = 22; +// 縦の路線図。列幅と線の太さ +const RAIL_COLUMN_WIDTH = 32; +const RAIL_LINE_WIDTH = 6; -// 縦棒を流れる光の帯の高さ(px)。 -const TRACK_LIGHT_HEIGHT = 28; +// 行の高さ。通過駅を低くして、同じ画面高でより多くの駅を見せる +const STOP_ROW_HEIGHT = 52; +const PASS_ROW_HEIGHT = 36; -// 停車駅リストの上下パディング。光の開始位置(行の y)を stopList 座標へ -// 変換するのにも使う。 +// 駅の丸。強調(次の停車駅・停車中の駅)だけ一回り大きくする +const STOP_DOT_SIZE = 14; +const STOP_DOT_BORDER = 4; +const FOCUS_DOT_SIZE = 18; +const FOCUS_DOT_BORDER = 5; +const PASS_DOT_SIZE = 6; + +// 列車位置ピンの実寸。viewBox 28x37 を縮めて使う +const MARKER_WIDTH = 20; +const MARKER_HEIGHT = 26; +// ピンの円中心(viewBox 上の cy=14)を縮小後の座標に直した値。 +// 走行中はこの分だけ持ち上げて、円中心をセグメント上端(=発車済みとの境界)に合わせる。 +const MARKER_HEAD_OFFSET = Math.round((14 / 37) * MARKER_HEIGHT); + +// 停車駅リストの上下パディング const STOP_LIST_PADDING_V = 12; -// 停車駅リストの1行の高さ(stopRow の minHeight)。スクロール時に現在駅の -// 1つ前の駅が見える程度の余白を残すために使う。 -const STOP_ROW_HEIGHT = 72; +// のりかえ一覧の行。路線マーク(35)と2〜3行のテキストが収まる高さ +const TRANSFER_ROW_MIN_HEIGHT = isTablet ? 66 : 46; + +// 路線と路線の間隔。間隔が無いと路線名と次の路線名が地続きに見えて、 +// どこまでが1つの路線の情報なのか読み取れないため、行間で区切りを作る +const TRANSFER_ROW_GAP = isTablet ? 16 : 12; + +// のりかえ一覧に添える駅ナンバリングの枠。アイコン自体は実寸で組まれているので +// この枠に合わせて縮小する +const TRANSFER_NUMBERING_SIZE = isTablet ? 48 : 32; + +// のりかえへの切り替わり。ふわりと乗るだけの短い動きに留める +const TRANSFER_FADE_DURATION = 220; +const TRANSFER_FADE_SHIFT = 10; + +// リスト下端のフェード。最終行がホームインジケータへ溶けるようにする +const LIST_FADE_HEIGHT = 72; + +// 各駅の到着予測を出す列の幅。3桁+単位が収まる固定幅を確保し、値の桁数で +// 右端が動かないようにして数字を縦に読める列にする。 +const ETA_COLUMN_WIDTH = isTablet ? 46 * 1.5 : 46; + +// 1分未満に丸まった駅。0分と出すと「もう着いた」と読めてしまうので語で出す +const ETA_SOON_THRESHOLD_MIN = 1; + +// ETAが取れていない駅のプレースホルダ。値のある駅と桁位置を揃えて置く +const ETA_PLACEHOLDER = '--'; + +// 現在地まわりに敷く路線色のにじみ。ダークでは発光、ライトでは淡い染みに見える +const WASH_WIDTH = 430; +const WASH_HEIGHT = 380; +const WASH_LEFT = -60; +const WASH_OPACITY_DARK = 0.16; +const WASH_OPACITY_LIGHT = 0.07; const styles = StyleSheet.create({ root: { flex: 1, - backgroundColor: COLORS.background, }, - lineSection: { + wash: { + position: 'absolute', + top: 0, + left: WASH_LEFT, + }, + metaRow: { flexDirection: 'row', - alignItems: 'stretch', + alignItems: 'center', paddingHorizontal: CONTENT_INSET, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: COLORS.divider, }, - // 停車駅リストの縦棒(trackLine)と同じ太さに揃える lineColorBar: { - width: TRACK_LINE_WIDTH, - }, - lineSectionBody: { - flex: 1, - paddingLeft: 16, - paddingVertical: 12, - }, - lineNameRow: { - flexDirection: 'row', - alignItems: 'center', + width: 4, + height: RFValue(13), + borderRadius: 2, }, lineName: { - flex: 1, - color: COLORS.textPrimary, - fontSize: RFValue(16), + flexShrink: 1, + marginLeft: 10, + fontSize: RFValue(12), fontWeight: 'bold', }, trainTypeBadge: { - paddingHorizontal: 8, - paddingVertical: 3, marginLeft: 8, + paddingHorizontal: 6, + paddingVertical: 3, + borderRadius: 2, }, trainTypeText: { - fontSize: RFValue(12), + fontSize: RFValue(9), fontWeight: 'bold', }, - // 行き先・状態テキスト・駅名はヘッダーの言語切り替えタイマーで内容が変わる。 - // 和文と欧文でフォントメトリクスが異なり行の高さが変動するため、 - // 高さと lineHeight を固定してセクション全体がガタつかないようにする。 + metaSpacer: { + flex: 1, + minWidth: 8, + }, + // 行き先は表示言語によって和文・欧文が入れ替わる。フォントメトリクスが異なり + // 行の高さが変動するため、高さと lineHeight を固定して行がガタつかないようにする。 boundText: { - marginTop: 4, - color: COLORS.textSecondary, - fontSize: RFValue(13), + flexShrink: 1, + fontSize: RFValue(10), fontWeight: 'bold', - height: RFValue(20), - lineHeight: RFValue(20), + height: RFValue(15), + lineHeight: RFValue(15), }, - stationSection: { - alignItems: 'stretch', - paddingVertical: 20, - paddingHorizontal: CONTENT_INSET, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: COLORS.divider, + card: { + marginTop: 18, + marginHorizontal: CARD_INSET, + paddingVertical: 18, + paddingHorizontal: 20, + borderRadius: 16, + borderWidth: StyleSheet.hairlineWidth, }, + cardHeadRow: { + flexDirection: 'row', + alignItems: 'center', + }, + // 状態テキストも言語切り替えで内容が変わるため高さを固定する stateText: { - color: COLORS.textSecondary, - fontSize: RFValue(18), + flexShrink: 1, + fontSize: RFValue(11), + fontWeight: 'bold', + height: RFValue(17), + lineHeight: RFValue(17), + }, + cardHeadRule: { + flex: 1, + minWidth: 8, + height: StyleSheet.hairlineWidth, + marginHorizontal: 10, + }, + cardHeadMeta: { + flexShrink: 1, + fontSize: RFValue(9), fontWeight: 'bold', - height: RFValue(28), - lineHeight: RFValue(28), }, stationNameRow: { flexDirection: 'row', @@ -239,8 +363,7 @@ const styles = StyleSheet.create({ paddingLeft: 8, }, stationNameText: { - color: COLORS.textPrimary, - fontSize: RFValue(32), + fontSize: RFValue(34), fontWeight: 'bold', }, // 自然幅の測定専用(非表示)。絶対配置 + 十分広い固定幅 + 左寄せで、親スロットの @@ -253,22 +376,48 @@ const styles = StyleSheet.create({ alignItems: 'flex-start', opacity: 0, }, + progressTrack: { + marginTop: 12, + height: 4, + borderRadius: 2, + overflow: 'hidden', + }, + progressFill: { + height: '100%', + borderRadius: 2, + }, stopList: { flex: 1, overflow: 'hidden', }, stopListContent: { - paddingVertical: STOP_LIST_PADDING_V, + paddingTop: STOP_LIST_PADDING_V, paddingHorizontal: CONTENT_INSET, }, + listFade: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + height: LIST_FADE_HEIGHT, + }, stopRow: { flexDirection: 'row', alignItems: 'stretch', - // 駅名の下に番号・乗換ドットを絶対配置するぶん、行間を広めに確保する。 + }, + stopRowStop: { minHeight: STOP_ROW_HEIGHT, }, - trackColumn: { - width: TRACK_COLUMN_WIDTH, + // 通過駅は停車駅より低くして、同じ画面高に入る駅数を増やす + stopRowPass: { + minHeight: PASS_ROW_HEIGHT, + }, + // 列車マーカーの行。ピンが前後の行の縦棒より前に出るようにする + stopRowElevated: { + zIndex: 2, + }, + railColumn: { + width: RAIL_COLUMN_WIDTH, alignItems: 'center', }, trackSegment: { @@ -279,29 +428,16 @@ const styles = StyleSheet.create({ // 行高は小数を含むためセグメント境界が物理ピクセルに揃わず、丸めの // 具合で白い継ぎ目が出ることがある。上下1pxずつ食み出させて隣接する // セグメント同士を重ね、継ぎ目が出ないようにする。 + // 端は丸めない。セグメントごとに丸めると隣接する丸い端同士が重なり、 + // 行の境目に節が浮き出る(実機で確認)。角のない帯にすると一本に繋がる。 trackLine: { position: 'absolute', top: -1, bottom: -1, - width: TRACK_LINE_WIDTH, - }, - // 停車駅リストの縦棒の上を1本だけ流す光。ScrollView コンテンツ内に置き、 - // 縦棒の x 位置・幅に合わせて重ねる。zIndex は縦棒・ドット(=0)より上、 - // 列車マーカーの行(=2)より下に置き、光がピンの裏を通るようにする。 - trackLight: { - position: 'absolute', - top: 0, - left: (TRACK_COLUMN_WIDTH - TRACK_LINE_WIDTH) / 2, - width: TRACK_LINE_WIDTH, - height: TRACK_LIGHT_HEIGHT, - zIndex: 1, - }, - // 列車マーカーの行。光(zIndex 1)より上に出してピンの裏を光が通るようにする。 - stopRowElevated: { - zIndex: 2, + width: RAIL_LINE_WIDTH, }, // 列車位置マーカー(丸い頭+下向きの尖り)のコンテナ。絶対配置でセグメントの - // フロー高さに影響させず、ドット位置を chevron 種別に依らず一定に保つ。 + // フロー高さに影響させず、ドット位置をマーカーの有無に依らず一定に保つ。 trainMarker: { position: 'absolute', left: 0, @@ -309,110 +445,223 @@ const styles = StyleSheet.create({ alignItems: 'center', zIndex: 2, }, - // 発車後: 上の行との境目(=半透明と通常色の境界)にピンの円中心を合わせる。 - // ピンは高さ37・円中心 cy14 なので、円中心がセグメント上端(=境界)に来るよう - // top を -14 にする。これで半透明はピンの中央を超えない。 + // 発車後: 上の行との境目(=発車済みと通常色の境界)にピンの円中心を合わせる trainMarkerSegmentTop: { - top: -14, + top: -MARKER_HEAD_OFFSET, }, - // 停車中: マーカーの尖り(下端)を現在駅のドットのすぐ上に寄せる。 + // 停車中: マーカーの尖り(下端)を現在駅のドットのすぐ上に寄せる trainMarkerAboveDot: { bottom: 2, }, - // 白抜きの穴(内径 28-6*2=16px)を縦棒(幅22px)より狭くして、縦棒が穴を - // またいでリング(ボーダー)の内側へ潜り込むようにする。これで縦棒と - // リングの間に白い隙間が生じず、縦棒が駅の丸にシームレスに連結する。 - // 外径(28px)は縦棒(22px)より太く、駅の丸が縦棒から膨らんで見える。 - // 負マージンで上下セグメントを円の背後まで重ね、継ぎ目を出さない。 + // 白抜きの穴を縦棒より狭くして、縦棒が穴をまたいでリング(ボーダー)の内側へ + // 潜り込むようにする。これで縦棒とリングの間に隙間が生じず、縦棒が駅の丸に + // シームレスに連結する。負マージンで上下セグメントを円の背後まで重ねる。 stopDot: { - width: 28, - height: 28, - borderRadius: 14, - borderWidth: 6, - backgroundColor: COLORS.background, - marginVertical: -5, + width: STOP_DOT_SIZE, + height: STOP_DOT_SIZE, + borderRadius: STOP_DOT_SIZE / 2, + borderWidth: STOP_DOT_BORDER, + marginVertical: -3, + zIndex: 1, + }, + // 強調する駅(停車中の駅・次の停車駅)は一回り大きい丸で示す + focusDot: { + width: FOCUS_DOT_SIZE, + height: FOCUS_DOT_SIZE, + borderRadius: FOCUS_DOT_SIZE / 2, + borderWidth: FOCUS_DOT_BORDER, + marginVertical: -4, zIndex: 1, }, // 通過駅は縦棒より細い「抜き穴」で表現する。棒をまたぐリングだと // 円からはみ出した棒の直線エッジが見えてしまうため、穴を棒の内側に収める。 - // 高さぶんの負マージンでフロー占有を0にし、上下のセグメントを背後で連結する。 passDot: { - width: 10, - height: 10, - borderRadius: 5, - backgroundColor: COLORS.background, - marginVertical: -5, + width: PASS_DOT_SIZE, + height: PASS_DOT_SIZE, + borderRadius: PASS_DOT_SIZE / 2, + marginVertical: -3, zIndex: 1, }, - stopBody: { + // 駅名・通過ラベル・乗換ドット・駅番号を1行に横並びにする。旧デザインのように + // 駅名の下へ絶対配置しないので、次の行の駅名と重ならない。 + stopContent: { flex: 1, - marginLeft: 16, - justifyContent: 'center', + flexDirection: 'row', + alignItems: 'center', + marginLeft: 14, }, - stopBodyDeparted: { + stopContentDeparted: { opacity: 0.4, }, - // 駅名のラップ。高さは駅名のみ(サブ情報は absolute で含めない)なので、 - // stopBody の justifyContent:center で駅名が行の縦中央=丸の真横に来る。 - stopLabel: { - alignSelf: 'flex-start', - }, - // 番号・乗換ドットを駅名の真下に絶対配置。行の高さに影響させない。 - stopSubInfo: { - position: 'absolute', - top: '100%', - left: 0, + stopBody: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', }, stopName: { - color: COLORS.textPrimary, - fontSize: RFValue(16), + flexShrink: 1, + fontSize: RFValue(14), fontWeight: 'bold', }, - stopNameCurrent: { - fontSize: RFValue(18), + stopNameFocused: { + fontSize: RFValue(15), }, stopNamePass: { - color: COLORS.textPass, + fontSize: RFValue(11), fontWeight: 'normal', }, - stopNumbering: { - marginTop: -2, - color: COLORS.textSecondary, - fontSize: RFValue(11), - lineHeight: RFValue(13), + passLabel: { + marginLeft: 8, + fontSize: RFValue(8), fontWeight: 'bold', }, - stopNumberingPass: { - color: COLORS.textPass, - fontWeight: 'normal', - }, - transferDotsRow: { + transferDots: { flexDirection: 'row', alignItems: 'center', - marginTop: 6, - height: 10, + marginLeft: 10, }, transferDot: { - width: 10, - height: 10, - borderRadius: 5, - marginRight: 6, + width: 8, + height: 8, + borderRadius: 4, + marginRight: 5, + }, + stopNumber: { + marginLeft: 8, + fontSize: RFValue(10), + fontWeight: 'bold', + }, + // 到着予測。数字と単位のベースラインを揃えたうえで右寄せの固定幅に置く + etaColumn: { + minWidth: ETA_COLUMN_WIDTH, + marginLeft: 10, + flexDirection: 'row', + alignItems: 'baseline', + justifyContent: 'flex-end', + }, + etaValue: { + fontSize: RFValue(13), + fontWeight: 'bold', + }, + etaValueFocused: { + fontSize: RFValue(15), + }, + etaUnit: { + marginLeft: 2, + fontSize: RFValue(8), + fontWeight: 'bold', + }, + etaUnitFocused: { + fontSize: RFValue(9), + }, + // 「まもなく」は数字より字数が多いので一段小さくして列の幅に収める + etaSoon: { + fontSize: RFValue(10), + fontWeight: 'bold', + }, + // のりかえ案内。停車駅リストと同じ領域に重ねて出す + transferOverlay: { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + }, + transferPane: { + flex: 1, + paddingTop: STOP_LIST_PADDING_V, + paddingHorizontal: CONTENT_INSET, + }, + transferListContent: { + paddingTop: 10, + paddingBottom: STOP_LIST_PADDING_V, + }, + // rowGap は直接の子の間にしか入らない。行の親はタップ領域の Pressable なので、 + // ここに置かないと路線と路線の間が空かない。 + transferRows: { + rowGap: TRANSFER_ROW_GAP, + }, + transferRow: { + flexDirection: 'row', + alignItems: 'center', + minHeight: TRANSFER_ROW_MIN_HEIGHT, + }, + transferBody: { + flex: 1, + marginLeft: 5, + }, + transferNameRow: { + flexDirection: 'row', + alignItems: 'baseline', + }, + transferLineName: { + flexShrink: 1, + fontSize: RFValue(14), + fontWeight: 'bold', + }, + // 乗換先が案内中の駅と別名のときだけ添える駅名 + transferStationName: { + flexShrink: 1, + marginLeft: 8, + fontSize: RFValue(9), + fontWeight: 'bold', + }, + transferSubName: { + fontSize: RFValue(10), + fontWeight: 'bold', + }, + transferNumberingBox: { + width: TRANSFER_NUMBERING_SIZE, + height: TRANSFER_NUMBERING_SIZE, + marginLeft: 10, + }, + // transform はレイアウトに影響しないため、実寸の枠を絶対配置で中央に重ねてから + // 縮小する。こうすると枠の大きさは TRANSFER_NUMBERING_SIZE のまま保たれる。 + transferNumbering: { + position: 'absolute', + left: (TRANSFER_NUMBERING_SIZE - NUMBERING_COLUMN_WIDTH) / 2, + top: (TRANSFER_NUMBERING_SIZE - NUMBERING_COLUMN_WIDTH) / 2, + width: NUMBERING_COLUMN_WIDTH, + height: NUMBERING_COLUMN_WIDTH, + alignItems: 'center', + justifyContent: 'center', + transform: [{ scale: TRANSFER_NUMBERING_SIZE / NUMBERING_COLUMN_WIDTH }], }, }); -type ChevronPosition = 'above-dot' | 'segment-top' | null; +// 乗換先の駅名。横画面の Transfers と同じ体裁で出す +const stationLabel = (station: Station | null | undefined): string => { + if (!station) { + return ''; + } + const name = isJapanese + ? (station.name ?? '') + : (station.nameRoman ?? station.name ?? ''); + const stripped = name.replace(parenthesisRegexp, ''); + if (!stripped) { + return ''; + } + return isJapanese ? `${stripped}駅` : `${stripped} Sta.`; +}; + +type MarkerPosition = 'above-dot' | 'segment-top' | null; // 列車位置マーカー(下向きのティアドロップ型ピン)。丸い頭(円)と下の尖りを -// 1つのシルエットとして描く。塗りはラインカラー、白い縁取りを付けることで +// 1つのシルエットとして描く。塗りはラインカラー、地の色で縁取りを付けることで // 同色の縦棒(trackLine)に尖りが埋もれず、線路上に乗ったピンとして読める。 -// 円(head)と尖り(tail)はそれぞれ「白い大きめ図形→塗りの図形」の2層構成にし、 -// 同色図形の重なりで継ぎ目なく融合させつつ、外周だけ白を覗かせて縁取りにする。 -// 座標は viewBox 28x37・中心(14,14)・頭半径11、尖り先端を下に伸ばした接線三角形。 -const TrainMarkerPin = ({ color }: { color: string }) => ( - - {/* 白い縁取り層(頭半径13・尖りを一回り大きく) */} - - +// 円(head)と尖り(tail)はそれぞれ「地色の大きめ図形→塗りの図形」の2層構成にし、 +// 同色図形の重なりで継ぎ目なく融合させつつ、外周だけ地色を覗かせて縁取りにする。 +const TrainMarkerPin = ({ + color, + background, +}: { + color: string; + background: string; +}) => ( + + {/* 地色の縁取り層(頭半径13.5・尖りを一回り大きく) */} + + {/* ラインカラーの本体層(頭半径11) */} @@ -423,7 +672,15 @@ const TrainMarkerPin = ({ color }: { color: string }) => ( // moving(走行中・通過中)のときは上下にバウンスさせ、停車中との違いを動きで示す。 const PULSE_DURATION = 850; const BOUNCE_DURATION = 500; -const TrainMarker = ({ color, moving }: { color: string; moving: boolean }) => { +const TrainMarker = ({ + color, + background, + moving, +}: { + color: string; + background: string; + moving: boolean; +}) => { const pulse = useSharedValue(0); const bounce = useSharedValue(0); @@ -469,44 +726,7 @@ const TrainMarker = ({ color, moving }: { color: string; moving: boolean }) => { return ( - - - ); -}; - -// 縦棒の上を下方向へ流れる一筋の光。半透明の白いグラデーション帯を、 -// 列車位置(透明度が切り替わる境界 startY)からリスト下端まで translateY で -// スライドさせる。帯は縦棒の x 位置・幅に合わせて重ね、色のある縦棒上でだけ -// 光って見える(白いドット上では同化して消える)。発車済み(半透明)区間より -// 手前=列車の進行方向側だけを流れる。 -// 所要時間は駅数(距離)に依らず固定。リストが長いほど速く流れる。 -const LIGHT_DURATION = 3000; // ms(始点→下端の1往復にかかる時間) -const TrackLight = ({ startY, height }: { startY: number; height: number }) => { - const translateY = useSharedValue(startY); - - useEffect(() => { - translateY.value = startY; - translateY.value = withRepeat( - withTiming(height, { duration: LIGHT_DURATION }), - -1, - false - ); - return () => cancelAnimation(translateY); - }, [startY, height, translateY]); - - const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ translateY: translateY.value }], - })); - - return ( - - + ); }; @@ -514,18 +734,20 @@ const TrackLight = ({ startY, height }: { startY: number; height: number }) => { const TrackSegment = ({ color, hidden, - chevron = null, + marker = null, lineTestID, - // ピン(列車)の色。縦棒(color)が通過中などで淡色化されても、列車そのものを - // 表すピンは通常の路線色のままにしたいので別に受け取る。 + // ピン(列車)の色。縦棒(color)が発車済みで淡色化されても、列車そのものを + // 表すピンは通常のアクセント色のままにしたいので別に受け取る。 markerColor, + markerBackground, markerMoving = false, }: { color: string; hidden: boolean; - chevron?: ChevronPosition; + marker?: MarkerPosition; lineTestID?: string; markerColor?: string; + markerBackground?: string; markerMoving?: boolean; }) => ( @@ -537,58 +759,125 @@ const TrackSegment = ({ ]} /> {/* ピンは絶対配置にしてセグメントのフロー高さに影響させない。これにより - chevron の位置(segment-top/above-dot)が変わってもドット位置は不変。 */} - {chevron ? ( + marker の位置(segment-top/above-dot)が変わってもドット位置は不変。 */} + {marker ? ( - + ) : null} ); +// 各駅の到着予測。横画面の LineBoard と同じく「現在駅を0分とした残り分」を出す。 +// 単位を全行に添えるのは、LineBoard が最後のドットにだけ「分」を置くのは数字を +// ドットの中へ入れる都合で場所が無いからで、縦の列には置く場所があるため。 +// スクロールで単位だけ画面外に出ることもない。 +const EtaValue = ({ + minutes, + isFocused, + color, + placeholderColor, +}: { + minutes?: number | null; + isFocused: boolean; + color: string; + placeholderColor: string; +}) => { + if (minutes == null) { + return ( + + {ETA_PLACEHOLDER} + + ); + } + + // 0分と出すと「もう着いた」と読めてしまうので、丸めて0になる駅は語で出す + const rounded = Math.round(minutes); + if (rounded < ETA_SOON_THRESHOLD_MIN) { + return ( + + {translate('portraitEtaSoon')} + + ); + } + + return ( + <> + + {rounded} + + + {translate('portraitEtaUnit')} + + + ); +}; + const StopRow = ({ station, + colors, isFirst, isLast, - isCurrent, + isFocused, departed, - chevron, + marker, markerMoving, fallbackLineColor, elevated, onLayoutTop, + showEta, + estimatedMinutes, }: { station: Station; + colors: AppColors; isFirst: boolean; isLast: boolean; - isCurrent: boolean; + isFocused: boolean; departed: boolean; - chevron: ChevronPosition; + marker: MarkerPosition; markerMoving: boolean; fallbackLineColor: string; elevated?: boolean; onLayoutTop?: (y: number) => void; + /** ETAが1駅でも取れているか。取れていない路線では列ごと出さない */ + showEta: boolean; + estimatedMinutes?: number | null; }) => { const isPass = getIsPass(station); // 直通運転で路線が変わったら縦棒も直通先のラインカラーで塗る const lineColor = station.line?.color ?? fallbackLineColor; const accentColor = useMemo( - () => readableAccentColor(lineColor), - [lineColor] + () => accentColorFor(lineColor, colors.isDark), + [lineColor, colors.isDark] ); // 列車が過ぎた線路(departed)だけを不透明の淡い色でフェードする(半透明だと // 縦棒とリングの重なりが濃くなるため不透明のままフェード)。テキストは - // stopBody の opacity でフェードする。 + // stopContent の opacity でフェードする。 const trackColor = useMemo( - () => (departed ? departedTrackColor(lineColor) : lineColor), - [departed, lineColor] + () => + departed + ? departedTrackColor(accentColor, colors.background) + : accentColor, + [departed, accentColor, colors.background] ); const getStationNumberIndex = useStationNumberIndexFunc(); const transferLines = useTransferLinesFromStation(station, { @@ -603,30 +892,48 @@ const StopRow = ({ ? (station.name ?? '') : (station.nameRoman ?? station.name ?? ''); + const passColor = passTextColor(colors); + const nameColor = isFocused ? accentColor : isPass ? passColor : colors.text; + const numberColor = isFocused + ? accentColor + : isPass + ? passColor + : colors.secondaryText; + return ( onLayoutTop(e.nativeEvent.layout.y) : undefined } > - + {/* 全駅表示なので上側は始発駅(isFirst)、下側は終点(isLast)でのみ 隠す。中間駅は前後の駅と線が繋がる。 */} {isPass ? ( - + ) : ( )} @@ -637,50 +944,57 @@ const StopRow = ({ /> - {/* 駅名を行の縦中央に置き、丸(縦棒の中央)と真横に揃える。 - 番号・乗換ドットは駅名の下に絶対配置して行の高さに影響させない。 */} - + {stationName} - - {stationNumber ? ( - - {stationNumber} - - ) : null} - {/* 乗り換えドットの行は常に同じ高さを確保する(ドットは有るときだけ描画)。 */} - - {!isPass && - transferLines.slice(0, 8).map((line) => ( - - ))} + {isPass ? ( + + {translate('portraitPassLabel')} + + ) : null} + {!isPass && transferLines.length ? ( + + {transferLines.slice(0, 8).map((line) => ( + + ))} - + ) : null} + {stationNumber ? ( + + {stationNumber} + + ) : null} + {/* 通過駅は停車しないので出さない。値の無い停車駅でも列は残して、 + 取得が届いたときに行の右端が動かないようにする。 */} + {showEta && !isPass ? ( + + + + ) : null} ); @@ -690,9 +1004,11 @@ const StopRow = ({ // 1行に収める。自然幅を非表示テキストで測り、スロット幅に収まる scaleX を当てる。 const StationName = ({ text, + color, withNumbering, }: { text: string; + color: string; withNumbering: boolean; }) => { const [availableWidth, setAvailableWidth] = useState(0); @@ -750,7 +1066,7 @@ const StationName = ({ @@ -767,6 +1083,7 @@ const StationName = ({ testID="portrait-station-name" style={[ styles.stationNameText, + { color }, renderWidth > 0 ? { width: renderWidth, @@ -786,11 +1103,258 @@ const StationName = ({ ); }; -const PortraitMain: React.FC = () => { +// 現在地まわりに敷く路線色のにじみ。ダークでは発光、ライトでは淡い染みに見える。 +const AccentWash = ({ color, opacity }: { color: string; opacity: number }) => { + // url(#id) で参照するため、useId が返すコロンなどを落として SVG の識別子として + // 妥当な文字列にする。 + const gradientId = `portraitWash${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`; + + return ( + + + + + + + + + + ); +}; + +// のりかえ案内。停車駅リストと同じ領域を占めて、下部の表示状態が TRANSFER の +// 間だけ上に重なる。行をタップすると横画面と同じく運転路線の切り替え確認へ渡す。 +const PortraitTransfers = ({ + lines, + transferStation, + colors, + accentColor, + bottomInset, + onPress, + onScrollBeginDrag, +}: { + lines: Line[]; + transferStation: Station | null; + colors: AppColors; + accentColor: string; + bottomInset: number; + onPress?: (station?: Station) => void; + onScrollBeginDrag?: () => void; +}) => { + const getLineMarkFunc = useGetLineMark(); + const stationNumbers = useTransferStationNumbers(lines); + const enabledLanguages = useAtomValue(enabledLanguagesAtom); + + const isJaEnabled = enabledLanguages.includes('JA'); + const isEnEnabled = enabledLanguages.includes('EN'); + const isZhEnabled = enabledLanguages.includes('ZH'); + const isKoEnabled = enabledLanguages.includes('KO'); + + const passColor = passTextColor(colors); + + return ( + + {/* 見出しは現在駅カードの頭と同じ組み方にして、画面内の調子を揃える */} + onPress?.()} + > + + {translate('transfer')} + + + {transferStation ? ( + + {stationLabel(transferStation)} + + ) : null} + + + + {/* 停車駅リストと同じ理由で、タップ領域は ScrollView の中に置く */} + onPress?.()} + > + {lines.map((line, index) => { + const lineMark = getLineMarkFunc({ + line, + stationNumbers: line.station?.stationNumbers, + }); + const numbering = stationNumbers[index]; + + // 乗換先が案内中の駅と別の駅のときだけ駅名を添える(新線新宿など)。 + // 幅の狭い縦画面で同じ駅名を全行に並べても情報が増えないため。 + const showStationName = + !!line.station && + line.station.groupId !== transferStation?.groupId; + + const cjkLineName = [ + isZhEnabled ? line.nameChinese : null, + isKoEnabled ? line.nameKorean : null, + ] + .filter((t): t is string => !!t?.length) + .map((t) => t.replace(parenthesisRegexp, '')) + .join(' / '); + + return ( + { + if (!line.station) { + return; + } + onPress?.({ + ...line.station, + __typename: 'Station', + line, + lines, + } as Station); + }} + > + {lineMark ? ( + + ) : ( + + )} + + + + {isJaEnabled && line.nameShort ? ( + + {line.nameShort.replace(parenthesisRegexp, '')} + + ) : null} + {showStationName ? ( + + {stationLabel(line.station)} + + ) : null} + + {isEnEnabled && line.nameRoman ? ( + + {line.nameRoman.replace(parenthesisRegexp, '')} + + ) : null} + {cjkLineName ? ( + + {cjkLineName} + + ) : null} + + + {numbering?.stationNumber ? ( + + + + + + ) : null} + + ); + })} + + + + ); +}; + +type Props = { + /** 画面タップ。横画面と同じく下部の表示を次へ進める */ + onPress?: () => void; + /** のりかえ行タップ。運転路線の切り替え確認へ渡す */ + onTransferPress?: (station?: Station) => void; +}; + +const PortraitMain: React.FC = ({ onPress, onTransferPress }) => { // ステータスバー非表示で全画面描画するため SafeAreaView は使わないが、 // Dynamic Island / ノッチやホームインジケータと表示が被らないよう、 // 上下のセーフエリア分を padding として確保する。 const insets = useSafeAreaInsets(); + // タブレットはノッチもホームインジケータもないぶんセーフエリアがほぼ 0 になり、 + // 上端だけ余白が消えて路線情報が画面の縁に貼り付いて見える。下端に確保している + // 余白と同じ量を最低限敷いて、上下の重さを揃える。 + const topInset = isTablet + ? Math.max(insets.top, STOP_LIST_PADDING_V + insets.bottom) + : insets.top; + const colors = useAtomValue(resolvedAppColorsAtom); const commonData = useHeaderCommonData(); const allStations = useAtomValue(stationsAtom); const selectedDirection = useAtomValue(selectedDirectionAtom); @@ -798,17 +1362,33 @@ const PortraitMain: React.FC = () => { const arrived = useAtomValue(arrivedAtom); const currentLine = useCurrentLine(); const trainType = useCurrentTrainType(); - // 行先は言語切り替えタイマーで多言語化せず日本語固定で表示する - const boundText = useBoundText().JA; + const { isLoopLine } = useLoopLine(); + const bottomState = useAtomValue(bottomStateAtom); + const transferLines = useTransferLines(); + // 案内する乗換路線と対象駅がずれないよう、路線側と同じフックから引く + const transferStation = useTransferTargetStation() ?? null; + // 行先はヘッダーの言語切り替えタイマーには追従させず、路線名・種別と同じく + // アプリの表示言語に合わせて固定表示する + const boundTextMap = useBoundText(); + const boundText = isJapanese ? boundTextMap.JA : boundTextMap.EN; + // 全駅を出すので leftStations で絞られない方のフックを使う + const { route: estimatedRoute } = useEstimateArrivalTimesAllStops(); + const estimatedMinutesByStationId = + useEstimatedMinutesByStationId(estimatedRoute); + // ETAが1駅も取れない路線(未取得・エラー・データなし)では列ごと出さない。 + // 全行に「--」が並び続けるより、右端を今までどおり空けておく方が素直。 + // 件数ではなく実値の有無で見る。stops は揃っていても cumulativeMinutes が + // すべて null の応答があり、件数だけでは「--」だけの列を出してしまう。 + const hasEta = useMemo( + () => + Array.from(estimatedMinutesByStationId.values()).some((m) => m != null), + [estimatedMinutesByStationId] + ); - const lineColor = currentLine?.color ?? COLORS.fallbackAccent; + const lineColor = currentLine?.color ?? FALLBACK_ACCENT; const accentColor = useMemo( - () => readableAccentColor(lineColor), - [lineColor] - ); - const stationSectionTint = useMemo( - () => lineTintColor(lineColor), - [lineColor] + () => accentColorFor(lineColor, colors.isDark), + [lineColor, colors.isDark] ); const trainTypeColor = normalizeTrainTypeColor(trainType?.color ?? undefined); const trainTypeTextColor = getTrainTypeTextColor( @@ -835,13 +1415,19 @@ const PortraitMain: React.FC = () => { // ポートレートでは路線の全駅を進行方向順(上から下)に表示する。 // 2路線の接続駅の重複を dropEitherJunctionStation で除いたうえで、 - // INBOUND は index 増加方向へ進むので昇順のまま、OUTBOUND は index 減少 - // 方向へ進むので反転する(useRefreshLeftStations と同じ向き)。 + // 進行方向が index 減少方向のときだけ反転する。 + // 通常の路線は INBOUND が index 増加方向だが、環状線(山手線・大阪環状線の + // 各駅停車・名城線・ディズニーリゾートライン)は向きが逆で、INBOUND が index + // 減少方向、OUTBOUND が増加方向になる(useSlicedStations / useNextStation / + // useRefreshLeftStations のループ線分岐と同じ向き)。 const stops = useMemo(() => { const list = allStations.filter((s): s is Station => !!s); const dropped = dropEitherJunctionStation(list, selectedDirection); - return selectedDirection === 'OUTBOUND' ? [...dropped].reverse() : dropped; - }, [allStations, selectedDirection]); + const shouldReverse = isLoopLine + ? selectedDirection === 'INBOUND' + : selectedDirection === 'OUTBOUND'; + return shouldReverse ? [...dropped].reverse() : dropped; + }, [allStations, selectedDirection, isLoopLine]); // 現在の最寄り駅(列車位置)の index。 const currentIndex = useMemo( @@ -849,6 +1435,13 @@ const PortraitMain: React.FC = () => { [stops, currentStation] ); + // 終点に到達したあと到着判定が外れると、arrived が false のまま現在駅は最終駅に + // 留まる(!arrived の間 useRefreshStation は現在駅を進めない)。素直に次駅へ進めると + // markerRowIndex が範囲外になり、列車ピンが消えて全行が発車済みの淡色になってしまう。 + // 最終駅より先へは進めないので、その駅に停車しているものとして描く。 + const atLastStation = currentIndex >= 0 && currentIndex === stops.length - 1; + const stoppedHere = arrived || atLastStation; + // 強調する停車駅。停車中は現在駅(停車駅)、発車後・通過中は次の停車駅。 const currentStopIndex = useMemo( () => @@ -856,46 +1449,145 @@ const PortraitMain: React.FC = () => { if (i < currentIndex) { return false; } - if (i === currentIndex && !arrived) { + if (i === currentIndex && !stoppedHere) { return false; } return !getIsPass(s); }), - [stops, currentIndex, arrived] + [stops, currentIndex, stoppedHere] ); - // 列車位置の三角(進行方向=下向き)。停車中は現在駅のドット直上、 - // 発車後は現在駅と次駅の境目=透明度が切り替わる位置(次駅行の上端)に出す。 - const chevronRowIndex = arrived ? currentIndex : currentIndex + 1; - const chevronPosition: ChevronPosition = arrived + // カード脇の注記に使う「次の停車駅」の位置。 + const nextStopIndex = useMemo(() => { + if (!stoppedHere) { + return currentStopIndex; + } + return stops.findIndex((s, i) => i > currentIndex && !getIsPass(s)); + }, [stops, currentIndex, currentStopIndex, stoppedHere]); + + // カード脇の注記。いま最寄りにしている駅が通過駅なら「◯◯を通過中」でその駅を + // 出す(カードは次の停車駅を出しているので、通過駅の名前はここにしか出ない)。 + // 停車中は「◯◯のつぎは△△」で、どの駅を起点にした次なのかを明示する + // (「つぎ △△」だけでは現在駅の次か、カードが出している駅の次かが読み取れない)。 + // 停車駅を発車して次の停車駅へ向かっている間はカードと同じことしか書けないので + // 何も出さない。 + const cardMetaText = useMemo(() => { + const label = (station: Station): string => + isJapanese + ? (station.name ?? '') + : (station.nameRoman ?? station.name ?? ''); + + const here = stops[currentIndex]; + if (here && getIsPass(here)) { + return translate('portraitPassThrough', { station: label(here) }); + } + + if (!stoppedHere || nextStopIndex < 0) { + return null; + } + const next = stops[nextStopIndex]; + if (!next) { + return null; + } + // 現在駅が路線内に見つからないときは起点を書きようがないので従来表記に落とす。 + return here + ? translate('portraitNextStopFrom', { + current: label(here), + station: label(next), + }) + : translate('portraitNextStop', { station: label(next) }); + }, [stops, currentIndex, nextStopIndex, stoppedHere]); + + // 列車位置のピン(進行方向=下向き)。停車中は現在駅のドット直上、 + // 発車後は現在駅と次駅の境目=発車済みの色が切り替わる位置(次駅行の上端)に出す。 + const markerRowIndex = stoppedHere ? currentIndex : currentIndex + 1; + const markerPosition: MarkerPosition = stoppedHere ? 'above-dot' : 'segment-top'; - // 走行中(!arrived)または通過中(現在駅が通過駅)はピンを上下にバウンスさせて + // 走行中または通過中(現在駅が通過駅)はピンを上下にバウンスさせて // 停車中との違いを示す。 - const markerMoving = !arrived || getIsPass(stops[currentIndex] ?? undefined); + const markerMoving = + !stoppedHere || getIsPass(stops[currentIndex] ?? undefined); + + // 乗換路線が無いときは useUpdateBottomState 側でも LINE へ戻されるが、 + // 戻るまでの間に空の案内が出ないようここでも見る。 + const showTransfer = bottomState === 'TRANSFER' && transferLines.length > 0; + + // のりかえは停車駅リストの上に重ねて出す。リストを外さないので、 + // 戻ってきたときもスクロール位置がそのまま保たれる。 + const transferOpacity = useSharedValue(0); + const transferShift = useSharedValue(0); + useEffect(() => { + if (!showTransfer) { + return; + } + transferOpacity.value = 0; + transferShift.value = TRANSFER_FADE_SHIFT; + transferOpacity.value = withTiming(1, { + duration: TRANSFER_FADE_DURATION, + }); + transferShift.value = withTiming(0, { duration: TRANSFER_FADE_DURATION }); + return () => { + cancelAnimation(transferOpacity); + cancelAnimation(transferShift); + }; + }, [showTransfer, transferOpacity, transferShift]); + const transferStyle = useAnimatedStyle(() => ({ + opacity: transferOpacity.value, + transform: [{ translateY: transferShift.value }], + })); + + // スクロールで指を離したときの press をタップと誤認しないようにする。 + // 1回のジェスチャの間にスクロールが始まったかどうかだけを覚えておき、 + // 指を置いた時点で倒す。ScrollView がドラッグを始めたら立てる。 + // (TouchableOpacity 側の取り消しはリスト内の行にしか効かず、画面全体の + // Pressable には届かないため、ここで明示的に区別する) + const draggedRef = useRef(false); + const handleTouchStart = useCallback(() => { + draggedRef.current = false; + }, []); + const handleScrollBeginDrag = useCallback(() => { + draggedRef.current = true; + }, []); + const handlePress = useCallback(() => { + if (draggedRef.current) { + return; + } + onPress?.(); + }, [onPress]); + const handleTransferPress = useCallback( + (station?: Station) => { + if (draggedRef.current) { + return; + } + onTransferPress?.(station); + }, + [onTransferPress] + ); - // listHeight はコンテンツ(全駅)の高さで光のスライド用。rowYs は各行の上端 y を - // 記録する(onLayout は行のレイアウト時にしか発火しないので、currentIndex 変更で - // コールバックが別行に移っても再取得できない。全行を記録しておき index で引く)。 - const [listHeight, setListHeight] = useState(0); + // rowYs は各行の上端 y を記録する(onLayout は行のレイアウト時にしか発火しないので、 + // currentIndex 変更でコールバックが別行に移っても再取得できない。全行を記録して + // index で引く)。 const [rowYs, setRowYs] = useState>({}); - const chevronRowY = rowYs[chevronRowIndex] ?? 0; + const markerRowY = rowYs[markerRowIndex] ?? 0; // 到着(ピン=現在駅)・出発(ピン=次駅)のたびに、ピンの行を表示領域の上 // (1つ前の駅が見える程度に1行分の余白を残した位置)へスクロールする。 const scrollRef = useRef(null); useEffect(() => { - if (chevronRowY > 0) { + if (markerRowY > 0) { scrollRef.current?.scrollTo({ - y: Math.max(0, STOP_LIST_PADDING_V + chevronRowY - STOP_ROW_HEIGHT), + y: Math.max(0, STOP_LIST_PADDING_V + markerRowY - STOP_ROW_HEIGHT), animated: true, }); } - }, [chevronRowY]); + }, [markerRowY]); if (!commonData) { - return ; + return ( + + ); } const { @@ -908,77 +1600,138 @@ const PortraitMain: React.FC = () => { // 停車中の英中韓 state を補完する const displayStateText = resolveStateText(stateText, headerState); + const progress = progressForState(headerState); return ( // ステータスバーは非表示のため SafeAreaView は使わず素の View を使う。 - // 上端は Dynamic Island / ノッチと路線セクションが被らないよう、 - // セーフエリア上端ぶんの padding を確保する。 + // 上端は Dynamic Island / ノッチと路線情報が被らないよう、 + // セーフエリア上端ぶん(タブレットでは下端の余白と同量)の padding を確保する。 - {/* 路線・行き先情報 */} - - - - - - {lineName} - - {trainTypeName ? ( - + + {/* 上部はスクロールしない領域なので、そのままタップ領域にしてよい */} + + {/* 路線・種別・行き先 */} + + + + {lineName} + + {trainTypeName ? ( + + - - {trainTypeName} - - - ) : null} - - + {trainTypeName} + + + ) : null} + + {boundText} - - {/* 駅名表示 */} - - - {displayStateText} - - - {currentStationNumber ? ( - - - - ) : null} - + + + {displayStateText} + + + {cardMetaText ? ( + // 起点と次駅の駅名が両方入るため、長い駅名同士だと1行に収まらない + // ことがある。末尾を削ると肝心の次駅が消えるので先頭側を省略する。 + + {cardMetaText} + + ) : null} + + + + {currentStationNumber ? ( + + {/* ナンバリングは地の上の文字ではなく独立した記号なので、 + 他のヘッダーと同じく路線色をそのまま渡して加工しない。 */} + + + ) : null} + + + + {/* 駅間の進み具合。到着すると満ちる */} + + + - + {/* 停車駅リスト */} { { paddingBottom: STOP_LIST_PADDING_V + insets.bottom }, ]} > - {/* 行と光を同じ座標系・スタッキング文脈に置くラッパー。光は縦棒の - 上(zIndex 1)、列車マーカーの行はさらに上(zIndex 2)に重なる。 */} - setListHeight(e.nativeEvent.layout.height)}> + {/* タップ領域は ScrollView の中に置く。こうするとスクロールが + 始まった時点で RN 側が press を取り消すので、スクロールと + タップが競合しない。 */} + {stops.map((station, index) => { - // 列車位置のピンより前の行(過ぎた線路)を半透明にする。停車中・ - // 走行中とも、ピンの行とそれ以降は通常色のまま(ピンの位置から - // 先は薄くしない)。 - const departed = index < chevronRowIndex; + // 列車位置のピンより前の行(過ぎた線路)を淡色にする。停車中・ + // 走行中とも、ピンの行とそれ以降は通常色のまま。 + const departed = index < markerRowIndex; return ( currentIndex} + estimatedMinutes={ + station.id != null + ? estimatedMinutesByStationId.get(station.id) + : null + } onLayoutTop={(y) => setRowYs((prev) => prev[index] === y ? prev : { ...prev, [index]: y } @@ -1014,12 +1777,35 @@ const PortraitMain: React.FC = () => { /> ); })} - {/* 列車位置(透明度の境界)から下端まで流れる一筋の光 */} - {listHeight > 0 ? ( - - ) : null} - + + {showTransfer ? ( + + + + ) : null} + + {/* 最終行がホームインジケータへ溶けるよう下端をぼかす */} + ); diff --git a/src/components/PortraitModePromoBanner.test.tsx b/src/components/PortraitModePromoBanner.test.tsx new file mode 100644 index 0000000000..87ae67f7cd --- /dev/null +++ b/src/components/PortraitModePromoBanner.test.tsx @@ -0,0 +1,133 @@ +import { act, fireEvent, render } from '@testing-library/react-native'; +import { createStore, Provider } from 'jotai'; +import { StrictMode } from 'react'; +import { STORAGE_KEYS } from '~/constants/storage'; +import { storage } from '~/lib/storage'; +import { + portraitModeEnabledAtom, + portraitPromoFinishedAtom, +} from '~/store/atoms/display'; +import { + canShowPortraitBanner, + finishPortraitPromo, + PORTRAIT_BANNER_MAX_COUNT, + recordPortraitBannerShown, +} from '~/utils/portraitPromo'; +import { PortraitModePromoBanner } from './PortraitModePromoBanner'; + +const mockNavigate = jest.fn(); +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ navigate: mockNavigate }), +})); + +jest.mock('~/translation', () => ({ + translate: (key: string) => key, +})); + +const renderBanner = (portraitModeEnabled = false) => { + const store = createStore(); + store.set(portraitModeEnabledAtom, portraitModeEnabled); + + return render( + + + + ); +}; + +describe('PortraitModePromoBanner', () => { + beforeEach(() => { + storage.clearAll(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('条件を満たすときに表示する', () => { + const { queryByTestId } = renderBanner(); + + expect(queryByTestId('portrait-mode-promo-banner')).toBeTruthy(); + }); + + it('ポートレートモードが有効なら表示しない', () => { + const { queryByTestId } = renderBanner(true); + + expect(queryByTestId('portrait-mode-promo-banner')).toBeNull(); + }); + + it('訴求を打ち切ったあとは表示しない', () => { + finishPortraitPromo(); + + const { queryByTestId } = renderBanner(); + + expect(queryByTestId('portrait-mode-promo-banner')).toBeNull(); + }); + + it('上限回数まで表示したら出さない', () => { + for (let i = 0; i < PORTRAIT_BANNER_MAX_COUNT; i++) { + recordPortraitBannerShown(); + } + + const { queryByTestId } = renderBanner(); + + expect(queryByTestId('portrait-mode-promo-banner')).toBeNull(); + }); + + it('表示したら回数を記録する', () => { + renderBanner(); + + // 1回表示した分が減るので、残りは上限-1回 + for (let i = 1; i < PORTRAIT_BANNER_MAX_COUNT; i++) { + expect(canShowPortraitBanner()).toBe(true); + recordPortraitBannerShown(); + } + expect(canShowPortraitBanner()).toBe(false); + }); + + it('StrictModeでeffectが二重に走っても1回しか数えない', () => { + const store = createStore(); + store.set(portraitModeEnabledAtom, false); + + render( + + + + + + ); + + expect(storage.getString(STORAGE_KEYS.PORTRAIT_PROMO_BANNER_COUNT)).toBe( + '1' + ); + }); + + it('一度オンにしたあとオフに戻されても復活しない', () => { + const store = createStore(); + store.set(portraitModeEnabledAtom, true); + store.set(portraitPromoFinishedAtom, true); + + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('portrait-mode-promo-banner')).toBeNull(); + + // 外観設定からオフに戻す + act(() => { + store.set(portraitModeEnabledAtom, false); + }); + + expect(queryByTestId('portrait-mode-promo-banner')).toBeNull(); + }); + + it('タップすると外観設定へ遷移する', () => { + const { getByTestId } = renderBanner(); + + fireEvent.press(getByTestId('portrait-mode-promo-banner')); + + expect(mockNavigate).toHaveBeenCalledWith('ColorSchemeSettings'); + }); +}); diff --git a/src/components/PortraitModePromoBanner.tsx b/src/components/PortraitModePromoBanner.tsx new file mode 100644 index 0000000000..26fadcb433 --- /dev/null +++ b/src/components/PortraitModePromoBanner.tsx @@ -0,0 +1,132 @@ +import { Ionicons } from '@expo/vector-icons'; +import { useNavigation } from '@react-navigation/native'; +import { useAtomValue } from 'jotai'; +import type React from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + type StyleProp, + StyleSheet, + TouchableOpacity, + View, + type ViewStyle, +} from 'react-native'; +import { CardChevron } from '~/components/CardChevron'; +import Typography from '~/components/Typography'; +import { useAppColors } from '~/providers/AppColorsProvider'; +import { + portraitModeEnabledAtom, + portraitPromoFinishedAtom, +} from '~/store/atoms/display'; +import { isLEDThemeAtom } from '~/store/atoms/theme'; +import { translate } from '~/translation'; +import { + canShowPortraitBanner, + recordPortraitBannerShown, +} from '~/utils/portraitPromo'; + +const styles = StyleSheet.create({ + root: { + height: 64, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + gap: 12, + }, + bg: { + borderRadius: 8, + // CommonCard と同じ影値 + boxShadow: '0px 0px 8px rgba(51, 51, 51, 0.25)', + }, + ledBg: { + backgroundColor: '#212121', + borderColor: '#fff', + borderWidth: 1, + }, + texts: { + flex: 1, + gap: 2, + }, + title: { + fontSize: 14, + fontWeight: 'bold', + }, + subtitle: { + fontSize: 12, + fontWeight: 'bold', + }, +}); + +type Props = { + style?: StyleProp; +}; + +/** + * ホームでポートレートモードの追加を知らせるバナー(案B)。 + * + * 起動した人全員に届く代わりに、走行前なので実感は伴わない。認知を配る役に徹して、 + * タップしたら外観設定へ送る(案Cのスポットライトがトグルを指す)。 + * 3回表示するか、ポートレートモードをオンにしたら二度と出さない。 + */ +export const PortraitModePromoBanner: React.FC = ({ style }: Props) => { + const navigation = useNavigation(); + const isLEDTheme = useAtomValue(isLEDThemeAtom); + const colors = useAppColors(); + const portraitModeEnabled = useAtomValue(portraitModeEnabledAtom); + // オンにしたあと同じセッション中にオフへ戻されてもバナーを復活させないため、 + // 打ち切り状態はマウント時の値ではなく atom で購読する + const promoFinished = useAtomValue(portraitPromoFinishedAtom); + + // 表示回数の上限判定は MMKV の同期 API で初回レンダー時に確定させる + const [countAllows] = useState(() => canShowPortraitBanner()); + const visible = countAllows && !promoFinished && !portraitModeEnabled; + + // StrictMode は初回マウントで effect を二度走らせる。素直に数えると + // 1回の表示で2回分減り、3回出すつもりが2回で打ち止めになる。 + // ref はマウントを跨いで保たれるので、1マウント1回に抑えられる + const recordedRef = useRef(false); + + useEffect(() => { + if (!visible || recordedRef.current) { + return; + } + recordedRef.current = true; + recordPortraitBannerShown(); + }, [visible]); + + const handlePress = useCallback(() => { + navigation.navigate('ColorSchemeSettings' as never); + }, [navigation]); + + if (!visible) { + return null; + } + + return ( + + + + + {translate('portraitPromoBannerTitle')} + + + {translate('portraitPromoBannerSubtitle')} + + + {/* 既定の stroke(#fff) は白背景で不可視になるため、テーマに応じて指定する */} + + + ); +}; diff --git a/src/components/PortraitModePrompt.tsx b/src/components/PortraitModePrompt.tsx new file mode 100644 index 0000000000..1738942031 --- /dev/null +++ b/src/components/PortraitModePrompt.tsx @@ -0,0 +1,153 @@ +import { LinearGradient } from 'expo-linear-gradient'; +import { useAtomValue } from 'jotai'; +import type React from 'react'; +import { memo } from 'react'; +import { Platform, Pressable, StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { usePortraitModePromo } from '~/hooks/usePortraitModePromo'; +import { appColorsAtom } from '~/store/atoms/colorScheme'; +import { isLEDThemeAtom } from '~/store/atoms/theme'; +import { translate } from '~/translation'; +import { RFValue } from '~/utils/rfValue'; +import Typography from './Typography'; + +// ウォークスルーと同じ「教える」ときの色 +const TEACHING_COLOR = '#03a9f4'; + +// カードを浮かせるための下方向のスクリム。走行画面全体は暗くしない +const SCRIM_HEIGHT = 400; + +const styles = StyleSheet.create({ + root: { + ...StyleSheet.absoluteFill, + zIndex: 9998, + }, + scrim: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + height: SCRIM_HEIGHT, + }, + card: { + position: 'absolute', + left: 24, + right: 24, + maxWidth: 640, + alignSelf: 'center', + borderRadius: 12, + padding: 20, + boxShadow: '0px 4px 16px rgba(0, 0, 0, 0.3)', + }, + title: { + fontSize: RFValue(18), + fontWeight: 'bold', + color: TEACHING_COLOR, + marginBottom: 8, + lineHeight: Platform.select({ + ios: RFValue(24), + }), + }, + description: { + fontSize: RFValue(14), + lineHeight: Platform.select({ + ios: RFValue(20), + android: RFValue(22), + }), + marginBottom: 16, + }, + footer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + dismissText: { + fontSize: RFValue(14), + }, + primaryButton: { + backgroundColor: TEACHING_COLOR, + paddingVertical: 8, + paddingHorizontal: 16, + borderRadius: 6, + }, + primaryButtonText: { + fontSize: RFValue(14), + color: '#fff', + fontWeight: 'bold', + }, + // LEDテーマは角丸を使わないため、角丸を持つ要素すべてに重ねて直角化する + squareCorners: { + borderRadius: 0, + }, +}); + +/** + * 端末を縦に持ったまま走行画面を見ている人に、ポートレートモードを提案するカード(案A)。 + * + * 走行画面は端末が縦のとき中身を90度回転して描画されるので、このカードは + * 回転しているビューの外側に置いて正立させること。配色は走行画面の外にある + * 操作系画面と同じライト/ダークに追従させるため、Provider ではなく atom を直接読む。 + */ +const PortraitModePrompt: React.FC = () => { + const { visible, enable, dismiss } = usePortraitModePromo(); + const colors = useAtomValue(appColorsAtom); + const isLEDTheme = useAtomValue(isLEDThemeAtom); + const insets = useSafeAreaInsets(); + + if (!visible) { + return null; + } + + return ( + + + + + {translate('portraitPromoPromptTitle')} + + + {translate('portraitPromoPromptDescription')} + + + + + + {translate('portraitPromoPromptDismiss')} + + + + + + {translate('portraitPromoPromptEnable')} + + + + + + ); +}; + +export default memo(PortraitModePrompt); diff --git a/src/components/ThemeConfirmModal.tsx b/src/components/ThemeConfirmModal.tsx index d14af4e50e..c1429b1ab2 100644 --- a/src/components/ThemeConfirmModal.tsx +++ b/src/components/ThemeConfirmModal.tsx @@ -116,6 +116,9 @@ export const ThemeConfirmModal: React.FC = ({ source={previewImage} style={styles.previewImage} contentFit="contain" + // モーダル表示中しか見えない大判プレビュー(タブレットで1枚8MB超のデコード + // サイズ)をメモリキャッシュに残さない。ディスクキャッシュのみで再表示する + cachePolicy="disk" /> )} diff --git a/src/components/ThemeListModal.test.tsx b/src/components/ThemeListModal.test.tsx index a53ad0d342..2e58051fe1 100644 --- a/src/components/ThemeListModal.test.tsx +++ b/src/components/ThemeListModal.test.tsx @@ -21,6 +21,9 @@ jest.mock('react-native-app-clip', () => ({ isClip: jest.fn(() => false), })); +// 本番ビルド相当。未公開テーマがこのモーダルから選べないことを担保する +jest.mock('~/utils/isDevApp', () => ({ isDevApp: false })); + jest.mock('../translation', () => ({ translate: (key: string) => key, isJapanese: false, @@ -73,6 +76,11 @@ describe('ThemeListModal', () => { expect(onSelect).toHaveBeenCalledWith(THEME_PREFERENCE.TOKYO_METRO); }); + it('本番ビルドでは devOnly なテーマが一覧に表示されない', () => { + const { queryByText } = render(); + expect(queryByText('lowPowerTheme')).toBeNull(); + }); + it('閉じるボタンを押すと onClose が呼ばれる', () => { const onClose = jest.fn(); const { getByText } = render( diff --git a/src/components/Transfers.tsx b/src/components/Transfers.tsx index e6c3064fe3..08cf1018f1 100644 --- a/src/components/Transfers.tsx +++ b/src/components/Transfers.tsx @@ -1,9 +1,13 @@ import { useAtomValue } from 'jotai'; -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback } from 'react'; import { FlatList, StyleSheet, TouchableOpacity, View } from 'react-native'; import type { Line, Station } from '~/@types/graphql'; import { NUMBERING_ICON_SIZE, parenthesisRegexp } from '../constants'; -import { useGetLineMark, useTransferLines } from '../hooks'; +import { + useGetLineMark, + useTransferLines, + useTransferStationNumbers, +} from '../hooks'; import type { AppTheme } from '../models/Theme'; import { enabledLanguagesAtom } from '../store/atoms/navigation'; import { isLEDThemeAtom } from '../store/atoms/theme'; @@ -76,49 +80,7 @@ const Transfers: React.FC = ({ onPress, theme }: Props) => { const isZhEnabled = enabledLanguages.includes('ZH'); const isKoEnabled = enabledLanguages.includes('KO'); - const stationNumbers = useMemo( - () => - lines?.map((l) => { - const stationNumberData = l.station?.stationNumbers?.find((sn) => - l.lineSymbols?.some((sym) => sym.symbol === sn.lineSymbol) - ); - const lineSymbol = stationNumberData?.lineSymbol ?? ''; - const lineSymbolColor = stationNumberData?.lineSymbolColor ?? ''; - const stationNumber = stationNumberData?.stationNumber ?? ''; - const lineSymbolShape = stationNumberData?.lineSymbolShape ?? 'NOOP'; - - if (!lineSymbol.length || !stationNumber.length) { - const stationNumberWhenEmptySymbol = - l.station?.stationNumbers?.find((sn) => !sn.lineSymbol?.length) - ?.stationNumber ?? ''; - const lineSymbolWhenEmptySymbol = l.lineSymbols?.[0]?.symbol ?? ''; - const lineSymbolColorWhenEmptySymbol = - l.station?.stationNumbers?.find((sn) => !sn.lineSymbol?.length) - ?.lineSymbolColor ?? '#000000'; - const lineSymbolShapeWhenEmptySymbol = - l.station?.stationNumbers?.find( - (sn) => !sn.lineSymbol?.length - )?.lineSymbolShape; - - return { - __typename: 'StationNumber' as const, - lineSymbol: lineSymbolWhenEmptySymbol, - lineSymbolColor: lineSymbolColorWhenEmptySymbol, - stationNumber: stationNumberWhenEmptySymbol, - lineSymbolShape: lineSymbolShapeWhenEmptySymbol, - }; - } - - return { - __typename: 'StationNumber' as const, - lineSymbol, - lineSymbolColor, - stationNumber, - lineSymbolShape, - }; - }), - [lines] - ); + const stationNumbers = useTransferStationNumbers(lines); const renderTransferLine = useCallback( ({ item: line, index }: { item: Line; index: number }) => { diff --git a/src/components/WalkthroughOverlay.test.tsx b/src/components/WalkthroughOverlay.test.tsx index ceb6179e6f..bbf19157f0 100644 --- a/src/components/WalkthroughOverlay.test.tsx +++ b/src/components/WalkthroughOverlay.test.tsx @@ -1,4 +1,4 @@ -import { render } from '@testing-library/react-native'; +import { fireEvent, render } from '@testing-library/react-native'; import { createStore, Provider } from 'jotai'; import { Path } from 'react-native-svg'; import { THEME_PREFERENCE } from '~/models/Theme'; @@ -170,11 +170,113 @@ describe('WalkthroughOverlay', () => { ); }); - it('LEDテーマではツールチップも角丸なしになる', () => { - const { getByLabelText } = renderOverlay(true); + it('通常テーマではツールチップがライト配色のカードになる', () => { + const { getByTestId, getByText, getByLabelText } = renderOverlay(false); - const nextButton = getByLabelText('walkthroughNext'); + expect(getByTestId('walkthrough-tooltip')).toHaveStyle({ + backgroundColor: '#FFFFFF', + borderRadius: 12, + }); + expect(getByText('settingsWalkthroughTitle2')).toHaveStyle({ + color: '#03a9f4', + }); + expect(getByText('settingsWalkthroughDescription2')).toHaveStyle({ + color: '#333333', + }); + expect(getByText('walkthroughSkip')).toHaveStyle({ color: '#666666' }); + expect(getByTestId('walkthrough-dot-0')).toHaveStyle({ + backgroundColor: '#03a9f4', + borderRadius: 4, + }); + expect(getByTestId('walkthrough-dot-1')).toHaveStyle({ + backgroundColor: '#DDDDDD', + }); + expect(getByLabelText('walkthroughNext')).toHaveStyle({ + backgroundColor: '#03a9f4', + borderRadius: 6, + }); + }); + + it('LEDテーマではツールチップが黒地・白枠・角丸なしになる', () => { + const { getByTestId, getByLabelText } = renderOverlay(true); + + expect(getByTestId('walkthrough-tooltip')).toHaveStyle({ + backgroundColor: '#212121', + borderColor: '#fff', + borderWidth: 1, + borderRadius: 0, + }); + expect(getByLabelText('walkthroughNext')).toHaveStyle({ + backgroundColor: '#212121', + borderColor: '#fff', + borderWidth: 1, + borderRadius: 0, + }); + }); + + it('LEDテーマでは文字とドットも白系の配色になる', () => { + const { getByTestId, getByText } = renderOverlay(true); + + expect(getByText('settingsWalkthroughTitle2')).toHaveStyle({ + color: '#fff', + }); + expect(getByText('settingsWalkthroughDescription2')).toHaveStyle({ + color: '#fff', + }); + expect(getByText('walkthroughSkip')).toHaveStyle({ color: '#CCCCCC' }); + expect(getByText('walkthroughNext')).toHaveStyle({ color: '#fff' }); + expect(getByTestId('walkthrough-dot-0')).toHaveStyle({ + backgroundColor: '#fff', + borderRadius: 0, + }); + expect(getByTestId('walkthrough-dot-1')).toHaveStyle({ + backgroundColor: '#444', + borderRadius: 0, + }); + }); +}); + +describe('背景タップ', () => { + const renderWithHandlers = (props: { + onNext: () => void; + onBackgroundPress?: () => void; + }) => { + const store = createStore(); + store.set(themePreferenceAtom, THEME_PREFERENCE.TOKYO_METRO); + + return render( + + + + ); + }; + + // オーバーレイ全面のPressable。ツールチップは別のViewなので含まれない + const pressBackground = (screen: ReturnType) => { + fireEvent.press(screen.getByTestId('walkthrough-backdrop')); + }; + + it('既定では背景タップで onNext が呼ばれる', () => { + const onNext = jest.fn(); + pressBackground(renderWithHandlers({ onNext })); + + expect(onNext).toHaveBeenCalledTimes(1); + }); + + it('onBackgroundPress を渡すと背景タップで onNext は呼ばれない', () => { + const onNext = jest.fn(); + const onBackgroundPress = jest.fn(); + pressBackground(renderWithHandlers({ onNext, onBackgroundPress })); - expect(nextButton).toHaveStyle({ borderRadius: 0 }); + expect(onBackgroundPress).toHaveBeenCalledTimes(1); + expect(onNext).not.toHaveBeenCalled(); }); }); diff --git a/src/components/WalkthroughOverlay.tsx b/src/components/WalkthroughOverlay.tsx index 3ccb0328cd..594d3a194f 100644 --- a/src/components/WalkthroughOverlay.tsx +++ b/src/components/WalkthroughOverlay.tsx @@ -10,6 +10,7 @@ import { } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Svg, { Defs, Mask, Path, Rect } from 'react-native-svg'; +import { LED_THEME_BG_COLOR } from '~/constants'; import { useAppColors } from '~/providers/AppColorsProvider'; import { isLEDThemeAtom } from '../store/atoms/theme'; import { translate } from '../translation'; @@ -44,8 +45,10 @@ export type WalkthroughStepId = | 'routeSearchResults' | 'settingsWelcome' | 'settingsTheme' + | 'settingsColorScheme' | 'settingsTts' - | 'settingsLanguages'; + | 'settingsLanguages' + | 'portraitMode'; export type WalkthroughStep = { id: WalkthroughStepId; @@ -63,6 +66,16 @@ type Props = { onNext: () => void; onGoToStep: (index: number) => void; onSkip: () => void; + /** 主ボタンのラベル。1ステップだけのスポットライトで文言を変えるために使う */ + primaryLabel?: string; + /** 左下のラベル。既定は「スキップ」 */ + dismissLabel?: string; + /** + * 背景(スポットライト以外)をタップしたときの動作。既定は onNext。 + * 主ボタンが設定変更などの副作用を持つステップでは、誤タップで実行されないよう + * 明示的に別のハンドラを渡すこと。 + */ + onBackgroundPress?: () => void; }; const ANIMATION_DURATION = 300; @@ -70,6 +83,12 @@ const ANIMATION_DURATION = 300; // spotlightArea.borderRadius が指定されていない場合の切り抜き半径 const DEFAULT_SPOTLIGHT_BORDER_RADIUS = 8; +// LEDテーマは appColorsAtom がライト配色を返すため colors.* をそのまま使うと白いカードになる。 +// 他のダイアログ(DialogModalLayout / Button)と同じ黒地・白枠・白文字の規則をここで持つ +const LED_TEXT_COLOR = '#fff'; +const LED_MUTED_TEXT_COLOR = '#CCCCCC'; +const LED_INACTIVE_DOT_COLOR = '#444'; + const styles = StyleSheet.create({ overlay: { ...StyleSheet.absoluteFill, @@ -138,6 +157,24 @@ const styles = StyleSheet.create({ squareCorners: { borderRadius: 0, }, + tooltipContainerLED: { + backgroundColor: LED_THEME_BG_COLOR, + borderWidth: 1, + borderColor: LED_TEXT_COLOR, + borderRadius: 0, + }, + titleLED: { + color: LED_TEXT_COLOR, + }, + dotActiveLED: { + backgroundColor: LED_TEXT_COLOR, + }, + nextButtonLED: { + backgroundColor: LED_THEME_BG_COLOR, + borderWidth: 1, + borderColor: LED_TEXT_COLOR, + borderRadius: 0, + }, }); const WalkthroughOverlay: React.FC = ({ @@ -148,6 +185,9 @@ const WalkthroughOverlay: React.FC = ({ onNext, onGoToStep, onSkip, + primaryLabel, + dismissLabel, + onBackgroundPress, }) => { const insets = useSafeAreaInsets(); const isLEDTheme = useAtomValue(isLEDThemeAtom); @@ -158,6 +198,12 @@ const WalkthroughOverlay: React.FC = ({ const [overlayOffset, setOverlayOffset] = useState({ x: 0, y: 0 }); const { spotlightArea, tooltipPosition = 'bottom' } = step; + const skipText = dismissLabel ?? translate('walkthroughSkip'); + const nextText = + primaryLabel ?? + (currentStepIndex === totalSteps - 1 + ? translate('walkthroughStart') + : translate('walkthroughNext')); // spotlightArea は measureInWindow() 由来の画面全体を基準にした座標。 // WalkthroughOverlay は共通ダイアログより手前に出ないよう Portal ではなく通常ツリー内に描画しているため、 // SVG とツールチップの座標基準はオーバーレイ自身の左上になる。 @@ -264,7 +310,11 @@ const WalkthroughOverlay: React.FC = ({ pointerEvents="box-none" onLayout={handleOverlayLayout} > - + @@ -290,15 +340,23 @@ const WalkthroughOverlay: React.FC = ({ - {translate(step.titleKey)} - + + {translate(step.titleKey)} + + {translate(step.descriptionKey)} @@ -306,55 +364,62 @@ const WalkthroughOverlay: React.FC = ({ - - {translate('walkthroughSkip')} + + {skipText} - - {Array.from({ length: totalSteps }).map((_, index) => ( - onGoToStep(index)} - hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }} - accessibilityRole="button" - accessibilityLabel={`${index + 1} / ${totalSteps}`} - accessibilityHint={translate('walkthroughGoToStepHint')} - > - - - ))} - + {totalSteps > 1 ? ( + + {Array.from({ length: totalSteps }).map((_, index) => ( + onGoToStep(index)} + hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }} + accessibilityRole="button" + accessibilityLabel={`${index + 1} / ${totalSteps}`} + accessibilityHint={translate('walkthroughGoToStepHint')} + > + + + ))} + + ) : null} - - {currentStepIndex === totalSteps - 1 - ? translate('walkthroughStart') - : translate('walkthroughNext')} - + {nextText} diff --git a/src/constants/index.ts b/src/constants/index.ts index c1740b2a18..9dd8bdfe88 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -7,6 +7,7 @@ export * from './ident'; export * from './languages'; export * from './line'; export * from './location'; +export * from './lowPowerTheme'; export * from './native'; export * from './numbering'; export * from './province'; diff --git a/src/constants/lowPowerTheme.ts b/src/constants/lowPowerTheme.ts new file mode 100644 index 0000000000..64e79e1a53 --- /dev/null +++ b/src/constants/lowPowerTheme.ts @@ -0,0 +1,26 @@ +/** + * 低消費電力テーマ(#3697)の配色と基準寸法。 + * + * 情報の区別は色相ではなく明度で行う。5段階の輝度差はモノクロへ落としても + * そのまま残るため、電子ペーパーのような単色表示でも同じ読み取りができる。 + * 括弧内は背景(純黒)に対するコントラスト比。 + */ +export const LOW_POWER_THEME_COLORS = { + /** 背景。有機ELでは画素が消灯する純黒 */ + background: '#000000', + /** 駅名・行先などの主情報 (21.0:1) */ + primary: '#FFFFFF', + /** ローマ字・駅番号・到着分などの副情報 (7.5:1) */ + secondary: '#9A9A9A', + /** 罫線・軌道・通過済みの駅 (4.1:1) */ + muted: '#6E6E6E', + /** 状態ラベルと列車位置。モノクロでは #BEBEBE 相当へ落ちる (11.5:1) */ + accent: '#FFB000', +} as const; + +/** + * レイアウトを起こした基準画面の短辺(dp)。Pixel 3 の横向き実寸 720x360 に合わせてある。 + * 各寸法はこの値に対する実機短辺(セーフエリアを除いた実効値)の比率で拡縮するので、 + * 低解像度端末では設計時とドット単位で同じ見た目になる。 + */ +export const LOW_POWER_BASE_HEIGHT = 360; diff --git a/src/constants/storage.ts b/src/constants/storage.ts index 405b1c633a..4b34910efe 100644 --- a/src/constants/storage.ts +++ b/src/constants/storage.ts @@ -35,6 +35,14 @@ export const STORAGE_KEYS = { PICTURE_IN_PICTURE_ENABLED: '@TrainLCD:pictureInPictureEnabled', PORTRAIT_MODE_ENABLED: '@TrainLCD:portraitModeEnabled', POWER_SAVING_LOCATION_ENABLED: '@TrainLCD:powerSavingLocationEnabled', + // ポートレートモードの訴求導線。オンにした時点で PROMO_FINISHED を立て、 + // 以降はオフに戻されても再訴求しない(一度判断した機能を売り込み直さない) + PORTRAIT_PROMO_FINISHED: '@TrainLCD:portraitPromoFinished', + PORTRAIT_PROMO_PROMPT_COUNT: '@TrainLCD:portraitPromoPromptCount', + PORTRAIT_PROMO_PROMPT_LAST_SHOWN_AT: + '@TrainLCD:portraitPromoPromptLastShownAt', + PORTRAIT_PROMO_BANNER_COUNT: '@TrainLCD:portraitPromoBannerCount', + PORTRAIT_PROMO_APPEARANCE_SEEN: '@TrainLCD:portraitPromoAppearanceSeen', } as const; export type StorageKeys = (typeof STORAGE_KEYS)[keyof typeof STORAGE_KEYS]; diff --git a/src/constants/theme.ts b/src/constants/theme.ts index a952aa6840..cbbba08f2e 100644 --- a/src/constants/theme.ts +++ b/src/constants/theme.ts @@ -19,6 +19,7 @@ export const IN_USE_COLOR_MAP: Record = { JR_KYUSHU: '#E50012', ODAKYU: '#0D82C7', E231: '#FFD400', + LOW_POWER: '#FFB000', } as const; export const AUTO_THEME_GRADIENT_COLORS: [string, string] = [ diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 3456e81bc9..8342e7c65f 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -23,6 +23,7 @@ export { useDisplayNextStation } from './useDisplayNextStation'; export { useDistanceToNextStation } from './useDistanceToNextStation'; export { useEnsureSelectLineInHistory } from './useEnsureSelectLineInHistory'; export { useEstimateArrivalTimes } from './useEstimateArrivalTimes'; +export { useEstimateArrivalTimesAllStops } from './useEstimateArrivalTimesAllStops'; export { useEstimateArrivalTimesRoute } from './useEstimateArrivalTimesRoute'; export { useEstimatedMinutesByStationId } from './useEstimatedMinutesByStationId'; export { useEtaAnchor } from './useEtaAnchor'; @@ -49,6 +50,7 @@ export { useIsNextLastStop } from './useIsNextLastStop'; export { useIsPassing } from './useIsPassing'; export { useIsTerminus } from './useIsTerminus'; export { useKeepAwake } from './useKeepAwake'; +export { useLandscapeSafeAreaInsets } from './useLandscapeSafeAreaInsets'; export { useLandscapeWindowDimensions } from './useLandscapeWindowDimensions'; export { useLazyGraphQLQuery } from './useLazyGraphQLQuery'; export { useLazyPrevious } from './useLazyPrevious'; @@ -56,6 +58,7 @@ export { useLineSelection } from './useLineSelection'; export { useLocationPermissionsGranted } from './useLocationPermissionsGranted'; export { useLoopLine } from './useLoopLine'; export { useLoopLineBound } from './useLoopLineBound'; +export { useLowPowerLayout } from './useLowPowerLayout'; export { useNearestStation } from './useNearestStation'; export { useNextLine } from './useNextLine'; export { useNextStation } from './useNextStation'; @@ -85,6 +88,8 @@ export { useThreshold } from './useThreshold'; export { useTrainTypeStations } from './useTrainTypeStations'; export { useTransferLines } from './useTransferLines'; export { useTransferLinesFromStation } from './useTransferLinesFromStation'; +export { useTransferStationNumbers } from './useTransferStationNumbers'; +export { useTransferTargetStation } from './useTransferTargetStation'; export { useTransitionHeaderState } from './useTransitionHeaderState'; export { useTTS } from './useTTS'; export { useTTSFeatureEnabled } from './useTTSFeatureEnabled'; diff --git a/src/hooks/tts/templates.ts b/src/hooks/tts/templates.ts index 1f76b6a529..f15470eab9 100644 --- a/src/hooks/tts/templates.ts +++ b/src/hooks/tts/templates.ts @@ -304,6 +304,7 @@ export const JA_TEMPLATES: Record = { [APP_THEME.JL]: EMPTY_THEME, [APP_THEME.ODAKYU]: EMPTY_THEME, [APP_THEME.E231]: EMPTY_THEME, + [APP_THEME.LOW_POWER]: EMPTY_THEME, }; export const EN_TEMPLATES: Record = { @@ -319,4 +320,5 @@ export const EN_TEMPLATES: Record = { [APP_THEME.JL]: EMPTY_THEME, [APP_THEME.ODAKYU]: EMPTY_THEME, [APP_THEME.E231]: EMPTY_THEME, + [APP_THEME.LOW_POWER]: EMPTY_THEME, }; diff --git a/src/hooks/tts/useNativeSpeechEngine.ts b/src/hooks/tts/useNativeSpeechEngine.ts index e17e9915d7..d7c9b04ea8 100644 --- a/src/hooks/tts/useNativeSpeechEngine.ts +++ b/src/hooks/tts/useNativeSpeechEngine.ts @@ -21,6 +21,9 @@ const EN_SPEECH_LANGUAGE = 'en-US'; // expo-speech は volume 未指定時の既定値が機種・OSバージョンにより最大音量 // より低くなることがあるため、常に最大値を明示指定して音量が下がる余地を無くす。 +// Android の KEY_PARAM_VOLUME は既定値が 1.0 なので指定しても挙動は変わらない +// (効くのは iOS 側)。音量ではなく音質の問題は音声選択で解く。 +// src/utils/nativeTtsVoice.ts を参照。 const MAX_SPEECH_VOLUME = 1.0; // 発話開始前に音声選択(getAvailableVoicesAsync)の完了を待つ上限(ミリ秒)。 @@ -75,6 +78,12 @@ export const useNativeSpeechEngine = (): SpeechEngine => { ); if (Platform.OS === 'android' && voices.length > 0) { androidVoicesLoadedRef.current = true; + // 端末ごとに音声のラインナップが違い、音質の当たり外れもここで決まる。 + // 実機で「どの音声が選ばれたか」を追えないと音質の切り分けができない + // ため、起動時の選択結果を 1 度だけ残す。 + console.warn( + `[useNativeSpeechEngine] Selected voices: ja=${jaVoiceIdRef.current ?? 'none'} en=${enVoiceIdRef.current ?? 'none'} (from ${voices.length} voices)` + ); if (!jaVoiceIdRef.current) { console.warn( '[useNativeSpeechEngine] No Japanese voice found on this device; Japanese announcements will be skipped' diff --git a/src/hooks/useEstimateArrivalTimes.ts b/src/hooks/useEstimateArrivalTimes.ts index c1dfdce84e..9600204dbc 100644 --- a/src/hooks/useEstimateArrivalTimes.ts +++ b/src/hooks/useEstimateArrivalTimes.ts @@ -1,5 +1,6 @@ import { useAtomValue } from 'jotai'; import { useMemo } from 'react'; +import { toRelativeEtaStops } from '~/utils/relativeEtaStops'; import { leftStationsAtom } from '../store/atoms/navigation'; import { useDisplayCurrentStation } from './useDisplayCurrentStation'; import { useEstimateArrivalTimesRoute } from './useEstimateArrivalTimesRoute'; @@ -9,6 +10,9 @@ import { useEstimateArrivalTimesRoute } from './useEstimateArrivalTimesRoute'; * ここでは表示都合の整形だけを行う薄いラッパー。 * 返す route.stops は LineBoard に表示中の駅(leftStations)に限定し、 * 現在駅の到着時刻を基準(0分)とした相対時間に変換する。 + * + * 全駅ぶんの ETA が要る画面(ポートレート)は leftStations で絞られると大半の駅が + * 欠けるため、絞り込みを持たない useEstimateArrivalTimesAllStops を使う。 */ export const useEstimateArrivalTimes = (options?: { skip?: boolean }) => { const leftStations = useAtomValue(leftStationsAtom); @@ -25,39 +29,14 @@ export const useEstimateArrivalTimes = (options?: { skip?: boolean }) => { return null; } - const allStops = route.stops ?? []; - - // 大江戸線の都庁前のように、環状区間(6の字運転)では同じ駅が全stops中に - // 複数回出現する。ただしこれらは stationGroupId(同一駅を束ねる論理グループ) - // こそ共通だが、stationId は出現ごとに別々に採番されている(例: 都庁前の - // 外回り/内回りはそれぞれ別の stationId を持つ)。そのため stationGroupId - // で突き合わせると無関係な出現まで拾ってしまうが、stationId なら出現ごとに - // 一意なので誤って混同することがない。 - const baseMinutes = - allStops.find((s) => s.stationId === currentStation?.id) - ?.departureCumulativeMinutes ?? 0; - const visibleStationIds = new Set(leftStations.map((ls) => ls.id)); - // 現在駅自身は cumulativeMinutes - baseMinutes が0以下になり通常は下のfilterで - // 除外されるが、区間内に現在駅のエントリが見つからずbaseMinutesが0に - // フォールバックするケースでは生の値が残ってしまう。停車中の駅にはETAを出さない - // という表示上の不変条件を計算結果に依存せず保証するため、ここで明示的に除く。 - const relativeStops = allStops - .filter( - (s) => - s.stationId != null && - visibleStationIds.has(s.stationId) && - s.stationId !== currentStation?.id - ) - .map((s) => ({ - ...s, - cumulativeMinutes: - s.cumulativeMinutes == null - ? null - : s.cumulativeMinutes - baseMinutes, - })) - .filter((s) => s.cumulativeMinutes == null || s.cumulativeMinutes > 0); + // 相対値への変換は全 stops を見てから行う。先に leftStations で絞ると、 + // 表示区間の外に出た現在駅を見失って基準が 0 にフォールバックしてしまう。 + const relativeStops = toRelativeEtaStops( + route.stops ?? [], + currentStation?.id + ).filter((s) => s.stationId != null && visibleStationIds.has(s.stationId)); return { ...route, stops: relativeStops }; }, [route, leftStations, currentStation?.id]); diff --git a/src/hooks/useEstimateArrivalTimesAllStops.test.tsx b/src/hooks/useEstimateArrivalTimesAllStops.test.tsx new file mode 100644 index 0000000000..f97e17b173 --- /dev/null +++ b/src/hooks/useEstimateArrivalTimesAllStops.test.tsx @@ -0,0 +1,83 @@ +import { renderHook } from '@testing-library/react-native'; +import { useDisplayCurrentStation } from './useDisplayCurrentStation'; +import { useEstimateArrivalTimesAllStops } from './useEstimateArrivalTimesAllStops'; +import { useEstimateArrivalTimesRoute } from './useEstimateArrivalTimesRoute'; + +jest.mock('./useDisplayCurrentStation', () => ({ + useDisplayCurrentStation: jest.fn(), +})); +jest.mock('./useEstimateArrivalTimesRoute', () => ({ + useEstimateArrivalTimesRoute: jest.fn(), +})); + +const mockedUseDisplayCurrentStation = useDisplayCurrentStation as jest.Mock; +const mockedUseEstimateArrivalTimesRoute = + useEstimateArrivalTimesRoute as jest.Mock; + +const setRoute = (route: unknown) => { + mockedUseEstimateArrivalTimesRoute.mockReturnValue({ + route, + loading: false, + error: null, + }); +}; + +describe('useEstimateArrivalTimesAllStops', () => { + beforeEach(() => { + mockedUseDisplayCurrentStation.mockReturnValue({ id: 2 }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('ルートが無いときは null を返す', () => { + setRoute(null); + + const { result } = renderHook(() => useEstimateArrivalTimesAllStops()); + + expect(result.current.route).toBeNull(); + }); + + it('leftStations で絞らず、全 stops を相対時間にして返す', () => { + // 横画面 LineBoard に出ていない駅も落とさないことがこのフックの存在理由 + setRoute({ + id: 1, + stops: [ + { stationId: 1, cumulativeMinutes: 0, departureCumulativeMinutes: 1 }, + { stationId: 2, cumulativeMinutes: 5, departureCumulativeMinutes: 6 }, + { stationId: 3, cumulativeMinutes: 12, departureCumulativeMinutes: 13 }, + { stationId: 4, cumulativeMinutes: 20, departureCumulativeMinutes: 21 }, + { stationId: 5, cumulativeMinutes: 31, departureCumulativeMinutes: 32 }, + ], + }); + + const { result } = renderHook(() => useEstimateArrivalTimesAllStops()); + + expect( + result.current.route?.stops.map((s) => [s.stationId, s.cumulativeMinutes]) + ).toEqual([ + [3, 6], + [4, 14], + [5, 25], + ]); + }); + + it('ルートの他のフィールドはそのまま持ち越す', () => { + setRoute({ id: 42, stops: [] }); + + const { result } = renderHook(() => useEstimateArrivalTimesAllStops()); + + expect(result.current.route?.id).toBe(42); + }); + + it('skip オプションをルート取得へそのまま渡す', () => { + setRoute(null); + + renderHook(() => useEstimateArrivalTimesAllStops({ skip: true })); + + expect(mockedUseEstimateArrivalTimesRoute).toHaveBeenCalledWith({ + skip: true, + }); + }); +}); diff --git a/src/hooks/useEstimateArrivalTimesAllStops.ts b/src/hooks/useEstimateArrivalTimesAllStops.ts new file mode 100644 index 0000000000..090a5c559d --- /dev/null +++ b/src/hooks/useEstimateArrivalTimesAllStops.ts @@ -0,0 +1,36 @@ +import { useMemo } from 'react'; +import { toRelativeEtaStops } from '~/utils/relativeEtaStops'; +import { useDisplayCurrentStation } from './useDisplayCurrentStation'; +import { useEstimateArrivalTimesRoute } from './useEstimateArrivalTimesRoute'; + +/** + * useEstimateArrivalTimes と同じ相対時間への変換を、leftStations による絞り込み + * 抜きで行う。ポートレートは路線の全駅を出すため、横画面 LineBoard の表示都合で + * 絞られた stops では画面に出ている駅の大半で ETA が欠ける。 + * + * 絞り込みを useEstimateArrivalTimes 側から外して共用すると LineBoard の表示条件が + * 変わってしまうので、共通する変換だけを toRelativeEtaStops に切り出したうえで + * フックは分けている。 + */ +export const useEstimateArrivalTimesAllStops = (options?: { + skip?: boolean; +}) => { + // 基準駅は useEstimateArrivalTimes と揃える。GPS の取りこぼしを前方補正した + // ときに基準がずれて、発車済みの駅に ETA が残るのを防ぐ。 + const currentStation = useDisplayCurrentStation(); + + const { route, loading, error } = useEstimateArrivalTimesRoute(options); + + const matchedRoute = useMemo(() => { + if (!route) { + return null; + } + + return { + ...route, + stops: toRelativeEtaStops(route.stops ?? [], currentStation?.id), + }; + }, [route, currentStation?.id]); + + return { route: matchedRoute, loading, error }; +}; diff --git a/src/hooks/useHeaderStateText.test.tsx b/src/hooks/useHeaderStateText.test.tsx new file mode 100644 index 0000000000..c7f62e78b4 --- /dev/null +++ b/src/hooks/useHeaderStateText.test.tsx @@ -0,0 +1,93 @@ +import { renderHook } from '@testing-library/react-native'; +import { useAtomValue } from 'jotai'; +import type { Station } from '~/@types/graphql'; +import type { HeaderLangState } from '~/models/HeaderTransitionState'; +import { APP_THEME } from '~/models/Theme'; +import { themeAtom } from '~/store/atoms/theme'; +import { createStation } from '~/utils/test/factories'; +import { headerStateAtom } from '../store/atoms/navigation'; +import { selectedBoundAtom } from '../store/atoms/station'; +import { useHeaderStateText } from './useHeaderStateText'; +import { useLoopLine } from './useLoopLine'; + +jest.mock('jotai', () => ({ + __esModule: true, + ...jest.requireActual('jotai'), + useAtomValue: jest.fn(), +})); + +jest.mock('./useLoopLine', () => ({ + __esModule: true, + useLoopLine: jest.fn(), +})); + +const mockUseAtomValue = useAtomValue as jest.MockedFunction< + typeof useAtomValue +>; +const mockUseLoopLine = useLoopLine as jest.MockedFunction; + +const osaki = createStation(1, { name: '大崎' }); + +const setAtomValues = (selectedBound: Station | null) => { + mockUseAtomValue.mockImplementation((atom: unknown) => { + if (atom === headerStateAtom) return 'CURRENT'; + if (atom === selectedBoundAtom) return selectedBound; + if (atom === themeAtom) return APP_THEME.TOKYO_METRO; + return undefined; + }); +}; + +const setLoopLine = (isLoopLine: boolean) => { + mockUseLoopLine.mockReturnValue({ + isLoopLine, + isYamanoteLine: isLoopLine, + isOsakaLoopLine: false, + isMeijoLine: false, + isOedoLine: false, + isDisneyResortLine: false, + isPartiallyLoopLine: false, + inboundStationsForLoopLine: [], + outboundStationsForLoopLine: [], + }); +}; + +const renderStateTextRight = (headerLangState: HeaderLangState) => + renderHook(() => + useHeaderStateText({ isLast: false, headerLangState, firstStop: true }) + ).result.current.stateTextRight; + +describe('useHeaderStateText', () => { + beforeEach(() => { + setAtomValues(osaki); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('環状線の乗車直後は行先に続く語を「方面」にする', () => { + setLoopLine(true); + expect(renderStateTextRight('JA')).toBe('方面'); + expect(renderStateTextRight('KANA')).toBe('方面'); + expect(renderStateTextRight('KO')).toBe('방면'); + }); + + it('環状線でない路線の乗車直後は「ゆき」のまま', () => { + setLoopLine(false); + expect(renderStateTextRight('JA')).toBe('ゆき'); + expect(renderStateTextRight('KANA')).toBe('ゆき'); + expect(renderStateTextRight('KO')).toBe('행'); + }); + + it('乗車直後でなければ行先に続く語は出さない', () => { + setLoopLine(true); + const { result } = renderHook(() => + useHeaderStateText({ + isLast: false, + headerLangState: 'JA', + firstStop: false, + }) + ); + expect(result.current.stateTextRight).toBe(''); + }); +}); diff --git a/src/hooks/useHeaderStateText.ts b/src/hooks/useHeaderStateText.ts index 1a0e2029e3..909220b7d6 100644 --- a/src/hooks/useHeaderStateText.ts +++ b/src/hooks/useHeaderStateText.ts @@ -6,6 +6,7 @@ import type { HeaderLangState } from '../models/HeaderTransitionState'; import { headerStateAtom } from '../store/atoms/navigation'; import { selectedBoundAtom } from '../store/atoms/station'; import { translate } from '../translation'; +import { useLoopLine } from './useLoopLine'; type UseHeaderStateTextOptions = { isLast: boolean; @@ -26,6 +27,7 @@ export const useHeaderStateText = ({ const headerState = useAtomValue(headerStateAtom); const selectedBound = useAtomValue(selectedBoundAtom); const currentTheme = useAtomValue(themeAtom); + const { isLoopLine } = useLoopLine(); const stateText = useMemo(() => { if (firstStop && selectedBound) { @@ -79,18 +81,20 @@ export const useHeaderStateText = ({ const stateTextRight = useMemo(() => { if (firstStop && selectedBound) { + // 環状線は行先が単一の終着駅ではなく方面(例: 新宿・池袋)なので、 + // 駅名に続く語も「ゆき」ではなく「方面」にする。 switch (headerLangState) { case 'JA': case 'KANA': - return 'ゆき'; + return isLoopLine ? '方面' : 'ゆき'; case 'KO': - return '행'; + return isLoopLine ? '방면' : '행'; default: return ''; } } return ''; - }, [firstStop, selectedBound, headerLangState]); + }, [firstStop, selectedBound, headerLangState, isLoopLine]); if ( currentTheme === APP_THEME.YAMANOTE || diff --git a/src/hooks/useHeaderStationText.test.tsx b/src/hooks/useHeaderStationText.test.tsx new file mode 100644 index 0000000000..938a5650c1 --- /dev/null +++ b/src/hooks/useHeaderStationText.test.tsx @@ -0,0 +1,169 @@ +import { renderHook } from '@testing-library/react-native'; +import { useAtomValue } from 'jotai'; +import type { Station } from '~/@types/graphql'; +import type { HeaderLangState } from '~/models/HeaderTransitionState'; +import { createStation } from '~/utils/test/factories'; +import { headerStateAtom } from '../store/atoms/navigation'; +import { + selectedBoundAtom, + selectedDirectionAtom, +} from '../store/atoms/station'; +import { useHeaderStationText } from './useHeaderStationText'; +import { useLoopLine } from './useLoopLine'; + +jest.mock('jotai', () => ({ + __esModule: true, + ...jest.requireActual('jotai'), + useAtomValue: jest.fn(), +})); + +jest.mock('./useLoopLine', () => ({ + __esModule: true, + useLoopLine: jest.fn(), +})); + +const mockUseAtomValue = useAtomValue as jest.MockedFunction< + typeof useAtomValue +>; +const mockUseLoopLine = useLoopLine as jest.MockedFunction; + +const shibuya = createStation(1, { + name: '渋谷', + nameKatakana: 'シブヤ', + nameRoman: 'Shibuya', +}); +const harajuku = createStation(2, { + name: '原宿', + nameKatakana: 'ハラジュク', + nameRoman: 'Harajuku', +}); +const osaki = createStation(3, { + name: '大崎', + nameKatakana: 'オオサキ', + nameRoman: 'Osaki', +}); +const shinjuku = createStation(4, { + name: '新宿', + nameKatakana: 'シンジュク', + nameRoman: 'Shinjuku', + nameChinese: '新宿', + nameKorean: '신주쿠', +}); +const ikebukuro = createStation(5, { + name: '池袋', + nameKatakana: 'イケブクロ', + nameRoman: 'Ikebukuro', + nameChinese: '池袋', + nameKorean: '이케부쿠로', +}); + +const setAtomValues = ({ + headerState, + selectedBound, + selectedDirection = 'INBOUND', +}: { + headerState: string; + selectedBound: Station | null; + selectedDirection?: 'INBOUND' | 'OUTBOUND'; +}) => { + mockUseAtomValue.mockImplementation((atom: unknown) => { + if (atom === headerStateAtom) return headerState; + if (atom === selectedBoundAtom) return selectedBound; + if (atom === selectedDirectionAtom) return selectedDirection; + return undefined; + }); +}; + +const setLoopLine = ( + isLoopLine: boolean, + { + inbound = [] as Station[], + outbound = [] as Station[], + }: { inbound?: Station[]; outbound?: Station[] } = {} +) => { + mockUseLoopLine.mockReturnValue({ + isLoopLine, + isYamanoteLine: isLoopLine, + isOsakaLoopLine: false, + isMeijoLine: false, + isOedoLine: false, + isDisneyResortLine: false, + isPartiallyLoopLine: false, + inboundStationsForLoopLine: inbound, + outboundStationsForLoopLine: outbound, + }); +}; + +const renderStationText = ( + headerLangState: HeaderLangState, + firstStop: boolean +) => + renderHook(() => + useHeaderStationText({ + currentStation: shibuya, + nextStation: harajuku, + headerLangState, + firstStop, + }) + ).result.current; + +describe('useHeaderStationText', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('環状線の乗車直後(firstStop)', () => { + beforeEach(() => { + setAtomValues({ headerState: 'CURRENT', selectedBound: osaki }); + setLoopLine(true, { inbound: [shinjuku, ikebukuro] }); + }); + + it('終着駅ではなく方面の主要駅を並べる', () => { + expect(renderStationText('JA', true)).toBe('新宿・池袋'); + }); + + it('カナ表示ではひらがなにして並べる', () => { + expect(renderStationText('KANA', true)).toBe('しんじゅく・いけぶくろ'); + }); + + it('英語表示では & で並べる', () => { + expect(renderStationText('EN', true)).toBe('Shinjuku & Ikebukuro'); + }); + + it('中国語・韓国語表示でも方面の主要駅を並べる', () => { + expect(renderStationText('ZH', true)).toBe('新宿・池袋'); + expect(renderStationText('KO', true)).toBe('신주쿠・이케부쿠로'); + }); + + it('進行方向が外回りなら外回り側の主要駅を使う', () => { + setAtomValues({ + headerState: 'CURRENT', + selectedBound: osaki, + selectedDirection: 'OUTBOUND', + }); + setLoopLine(true, { + inbound: [shinjuku, ikebukuro], + outbound: [osaki, shinjuku], + }); + expect(renderStationText('JA', true)).toBe('大崎・新宿'); + }); + }); + + it('環状線でも firstStop でなければ従来どおり駅名を出す', () => { + setAtomValues({ headerState: 'NEXT', selectedBound: osaki }); + setLoopLine(true, { inbound: [shinjuku, ikebukuro] }); + expect(renderStationText('JA', false)).toBe('原宿'); + }); + + it('環状線でない路線の firstStop は終着駅を出す', () => { + setAtomValues({ headerState: 'CURRENT', selectedBound: osaki }); + setLoopLine(false); + expect(renderStationText('JA', true)).toBe('大崎'); + }); + + it('環状線でも方面の主要駅が取れないときは終着駅に落とす', () => { + setAtomValues({ headerState: 'CURRENT', selectedBound: osaki }); + setLoopLine(true, { inbound: [] }); + expect(renderStationText('JA', true)).toBe('大崎'); + }); +}); diff --git a/src/hooks/useHeaderStationText.ts b/src/hooks/useHeaderStationText.ts index bb9b34e1b3..cd940d3553 100644 --- a/src/hooks/useHeaderStationText.ts +++ b/src/hooks/useHeaderStationText.ts @@ -4,9 +4,13 @@ import type { Station } from '~/@types/graphql'; import { parenthesisRegexp } from '~/constants'; import type { HeaderLangState } from '../models/HeaderTransitionState'; import { headerStateAtom } from '../store/atoms/navigation'; -import { selectedBoundAtom } from '../store/atoms/station'; +import { + selectedBoundAtom, + selectedDirectionAtom, +} from '../store/atoms/station'; import katakanaToHiragana from '../utils/kanaToHiragana'; import { isBusLine } from '../utils/line'; +import { useLoopLine } from './useLoopLine'; type UseHeaderStationTextOptions = { currentStation: Station | undefined; @@ -23,15 +27,54 @@ export const useHeaderStationText = ({ }: UseHeaderStationTextOptions): string => { const headerState = useAtomValue(headerStateAtom); const selectedBound = useAtomValue(selectedBoundAtom); + const selectedDirection = useAtomValue(selectedDirectionAtom); + const { + isLoopLine, + inboundStationsForLoopLine, + outboundStationsForLoopLine, + } = useLoopLine(); const isBus = isBusLine(currentStation?.line); + // 環状線は一周して起点に戻るため、終着駅を出しても行先の案内にならない。 + // 行先表示(useBoundText)と同じ主要駅を使い「新宿・池袋」のような方面で見せる。 + const loopLineBoundStations = useMemo(() => { + if (!isLoopLine) { + return []; + } + return selectedDirection === 'INBOUND' + ? inboundStationsForLoopLine + : outboundStationsForLoopLine; + }, [ + inboundStationsForLoopLine, + isLoopLine, + outboundStationsForLoopLine, + selectedDirection, + ]); + const rawText = useMemo(() => { if (!selectedBound) { return currentStation?.name ?? ''; } if (firstStop) { + if (loopLineBoundStations.length) { + switch (headerLangState) { + case 'KANA': + return loopLineBoundStations + .map((s) => katakanaToHiragana(s.nameKatakana)) + .join('・'); + case 'EN': + return loopLineBoundStations.map((s) => s.nameRoman).join(' & '); + case 'ZH': + return loopLineBoundStations.map((s) => s.nameChinese).join('・'); + case 'KO': + return loopLineBoundStations.map((s) => s.nameKorean).join('・'); + default: + return loopLineBoundStations.map((s) => s.name).join('・'); + } + } + switch (headerLangState) { case 'JA': return selectedBound.name ?? ''; @@ -104,6 +147,7 @@ export const useHeaderStationText = ({ selectedBound, firstStop, headerLangState, + loopLineBoundStations, ]); return isBus ? rawText.replace(parenthesisRegexp, '') : rawText; diff --git a/src/hooks/useLandscapeSafeAreaInsets.test.ts b/src/hooks/useLandscapeSafeAreaInsets.test.ts new file mode 100644 index 0000000000..f5de2b23c8 --- /dev/null +++ b/src/hooks/useLandscapeSafeAreaInsets.test.ts @@ -0,0 +1,20 @@ +import { getLandscapeSafeAreaInsets } from './useLandscapeSafeAreaInsets'; + +// ノッチが上、ホームインジケータが下にある実機を想定した非対称な値 +const INSETS = { top: 59, right: 21, bottom: 34, left: 39 }; + +describe('getLandscapeSafeAreaInsets', () => { + it('端末が横向きのときは実機の余白をそのまま返す', () => { + expect(getLandscapeSafeAreaInsets(INSETS, false)).toEqual(INSETS); + }); + + it('端末が縦向き(コンテンツが90deg回転)のときは辺を読み替える', () => { + // コンテンツの左端は実機の上端、上端は実機の右端に対応する + expect(getLandscapeSafeAreaInsets(INSETS, true)).toEqual({ + top: INSETS.right, + right: INSETS.bottom, + bottom: INSETS.left, + left: INSETS.top, + }); + }); +}); diff --git a/src/hooks/useLandscapeSafeAreaInsets.ts b/src/hooks/useLandscapeSafeAreaInsets.ts new file mode 100644 index 0000000000..1a6db4c910 --- /dev/null +++ b/src/hooks/useLandscapeSafeAreaInsets.ts @@ -0,0 +1,41 @@ +import { useMemo } from 'react'; +import { + type EdgeInsets, + useSafeAreaInsets, +} from 'react-native-safe-area-context'; +import { useLandscapeWindowDimensions } from './useLandscapeWindowDimensions'; + +/** + * 実機のセーフエリア余白を、Main 画面の横長レイアウト座標系へ読み替える。 + * + * 端末が物理的に portrait のとき、コンテンツは 90deg 回転して描画される。 + * 回転後は辺の対応がずれる(コンテンツの左端は実機の上端、上端は実機の右端)。 + */ +export const getLandscapeSafeAreaInsets = ( + insets: EdgeInsets, + isPortrait: boolean +): EdgeInsets => + isPortrait + ? { + top: insets.right, + right: insets.bottom, + bottom: insets.left, + left: insets.top, + } + : insets; + +/** + * Main 画面の横長レイアウト座標系に合わせたセーフエリア余白を返す。 + * + * useSafeAreaInsets() の値をそのまま padding に使うと、90deg 回転しているとき + * ノッチやホームインジケータを避けきれない。回転量に合わせて辺を入れ替える。 + */ +export const useLandscapeSafeAreaInsets = (): EdgeInsets => { + const insets = useSafeAreaInsets(); + const { isPortrait } = useLandscapeWindowDimensions(); + + return useMemo( + () => getLandscapeSafeAreaInsets(insets, isPortrait), + [insets, isPortrait] + ); +}; diff --git a/src/hooks/useLowPowerLayout.ts b/src/hooks/useLowPowerLayout.ts new file mode 100644 index 0000000000..12d403833b --- /dev/null +++ b/src/hooks/useLowPowerLayout.ts @@ -0,0 +1,34 @@ +import { useMemo } from 'react'; +import type { EdgeInsets } from 'react-native-safe-area-context'; +import { LOW_POWER_BASE_HEIGHT } from '../constants'; +import { useLandscapeSafeAreaInsets } from './useLandscapeSafeAreaInsets'; +import { useLandscapeWindowDimensions } from './useLandscapeWindowDimensions'; + +export type LowPowerLayout = { + /** セーフエリアを除いた描画領域の長辺 */ + width: number; + /** セーフエリアを除いた描画領域の短辺 */ + height: number; + /** 設計基準(720x360dp)に対する拡大率 */ + scale: number; + /** 横長座標系へ読み替え済みのセーフエリア余白 */ + insets: EdgeInsets; +}; + +/** + * ライトウェイト(コードネーム: 低消費電力)テーマの寸法計算の基準。 + * + * ノッチやホームインジケータを避けた実効の描画領域を返す。拡大率をそこから + * 起こすことで、セーフエリアが広い端末でもレイアウトが画面外へはみ出さない。 + * ヘッダーと停車駅ストリップで拡大率がずれないよう、両者はこのフックを共有する。 + */ +export const useLowPowerLayout = (): LowPowerLayout => { + const dim = useLandscapeWindowDimensions(); + const insets = useLandscapeSafeAreaInsets(); + + return useMemo(() => { + const width = dim.width - insets.left - insets.right; + const height = dim.height - insets.top - insets.bottom; + return { width, height, scale: height / LOW_POWER_BASE_HEIGHT, insets }; + }, [dim.width, dim.height, insets]); +}; diff --git a/src/hooks/useNumbering.test.tsx b/src/hooks/useNumbering.test.tsx index a6f12a0863..796de43af4 100644 --- a/src/hooks/useNumbering.test.tsx +++ b/src/hooks/useNumbering.test.tsx @@ -15,6 +15,7 @@ import { useCurrentLine } from './useCurrentLine'; import { useCurrentStation } from './useCurrentStation'; import { useCurrentTrainType } from './useCurrentTrainType'; import { useDisplayNextStation } from './useDisplayNextStation'; +import { useLoopLine } from './useLoopLine'; import { useNumbering } from './useNumbering'; import { useStationNumberIndexFunc } from './useStationNumberIndexFunc'; @@ -49,6 +50,11 @@ jest.mock('./useStationNumberIndexFunc', () => ({ useStationNumberIndexFunc: jest.fn(), })); +jest.mock('./useLoopLine', () => ({ + __esModule: true, + useLoopLine: jest.fn(), +})); + const TestComponent: React.FC<{ priorCurrent?: boolean; firstStop?: boolean; @@ -92,6 +98,23 @@ describe('useNumbering', () => { useStationNumberIndexFunc as jest.MockedFunction< typeof useStationNumberIndexFunc >; + const mockUseLoopLine = useLoopLine as jest.MockedFunction< + typeof useLoopLine + >; + + const mockLoopLine = (isLoopLine: boolean) => { + mockUseLoopLine.mockReturnValue({ + isLoopLine, + isYamanoteLine: isLoopLine, + isOsakaLoopLine: false, + isMeijoLine: false, + isOedoLine: false, + isDisneyResortLine: false, + isPartiallyLoopLine: false, + inboundStationsForLoopLine: [], + outboundStationsForLoopLine: [], + }); + }; const mockAtomValues = ({ arrived, @@ -115,6 +138,7 @@ describe('useNumbering', () => { mockUseStationNumberIndexFunc.mockReturnValue(() => 0); mockUseCurrentLine.mockReturnValue(createLine(1)); mockUseCurrentTrainType.mockReturnValue(null); + mockLoopLine(false); }); afterEach(() => { @@ -393,4 +417,51 @@ describe('useNumbering', () => { expect(getByTestId('stationNumber').props.children).toBe('undefined'); }); }); + + describe('環状線の乗車直後(firstStop)', () => { + // 行先が方面(複数駅)になるため、そのうち1駅の番号を添えると + // その駅ゆきに見えてしまう。番号もスリーレターコードも出さない。 + const buildLoopLineScenario = () => { + const bound = createStation(24, { + stationNumbers: [createStationNumber('JY', '24')], + stopCondition: StopCondition.All, + threeLetterCode: 'OSK', + }); + const currentStation = createStation(20, { + stationNumbers: [createStationNumber('JY', '20')], + stopCondition: StopCondition.All, + threeLetterCode: 'SBY', + }); + + mockAtomValues({ arrived: true, selectedBound: bound }); + mockUseCurrentStation.mockReturnValue(currentStation); + mockUseNextStation.mockReturnValue(undefined); + }; + + it('番号もスリーレターコードも出さない', async () => { + mockLoopLine(true); + buildLoopLineScenario(); + + const { getByTestId } = render(); + + await waitFor(() => { + expect(getByTestId('stationNumber').props.children).toBe('undefined'); + expect(getByTestId('threeLetterCode').props.children).toBe('undefined'); + }); + }); + + it('環状線でなければ従来どおり行先の番号を出す', async () => { + mockLoopLine(false); + buildLoopLineScenario(); + + const { getByTestId } = render(); + + await waitFor(() => { + expect(getByTestId('stationNumber').props.children).toBe( + JSON.stringify({ lineSymbol: 'JY', stationNumber: '24' }) + ); + expect(getByTestId('threeLetterCode').props.children).toBe('OSK'); + }); + }); + }); }); diff --git a/src/hooks/useNumbering.ts b/src/hooks/useNumbering.ts index 303eb63b41..f781f893ec 100644 --- a/src/hooks/useNumbering.ts +++ b/src/hooks/useNumbering.ts @@ -8,6 +8,7 @@ import { useCurrentLine } from './useCurrentLine'; import { useCurrentStation } from './useCurrentStation'; import { useCurrentTrainType } from './useCurrentTrainType'; import { useDisplayNextStation } from './useDisplayNextStation'; +import { useLoopLine } from './useLoopLine'; import { useStationNumberIndexFunc } from './useStationNumberIndexFunc'; export const useNumbering = ( @@ -24,6 +25,7 @@ export const useNumbering = ( const currentLine = useCurrentLine(); const currentStation = useCurrentStation(); + const { isLoopLine } = useLoopLine(); // まもなく表示時は現在地基準で接近している駅の番号を表示する const nextStation = useDisplayNextStation(); @@ -71,6 +73,14 @@ export const useNumbering = ( return; } + // 環状線の乗車直後は行先が単一の終着駅ではなく方面(複数駅)なので、 + // そのうち1駅の番号を添えるとその駅ゆきに見えてしまう。番号ごと出さない。 + if (firstStop && isLoopLine) { + setStationNumber(undefined); + setThreeLetterCode(undefined); + return; + } + if (priorCurrent && !getIsPass(targetStation)) { if (isJobanLineRapid) { const jjNumber = targetStation.stationNumbers?.find( @@ -125,7 +135,9 @@ export const useNumbering = ( arrived, currentStation, currentStationNumberIndex, + firstStop, isJobanLineRapid, + isLoopLine, nextStation?.stationNumbers, nextStation?.threeLetterCode, nextStationNumberIndex, diff --git a/src/hooks/usePortraitModePromo.test.tsx b/src/hooks/usePortraitModePromo.test.tsx new file mode 100644 index 0000000000..4447514379 --- /dev/null +++ b/src/hooks/usePortraitModePromo.test.tsx @@ -0,0 +1,175 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { createStore, Provider } from 'jotai'; +import type React from 'react'; +import { STORAGE_KEYS } from '~/constants/storage'; +import { storage } from '~/lib/storage'; +import { portraitModeEnabledAtom } from '~/store/atoms/display'; +import { arrivedAtom, selectedBoundAtom } from '~/store/atoms/station'; +import tuningState from '~/store/atoms/tuning'; +import { + isPortraitPromoFinished, + PORTRAIT_PROMPT_MAX_COUNT, + recordPortraitPromptDismissed, +} from '~/utils/portraitPromo'; +import { + PORTRAIT_HOLD_DURATION_MS, + usePortraitModePromo, +} from './usePortraitModePromo'; + +const mockDimensions = { width: 360, height: 780 }; +jest.mock('react-native/Libraries/Utilities/useWindowDimensions', () => ({ + __esModule: true, + default: () => mockDimensions, +})); + +jest.mock('~/translation', () => ({ + translate: (key: string) => key, +})); + +jest.mock('~/utils/dialogPresentation', () => ({ + showDialog: jest.fn(), +})); + +type Options = { + portraitModeEnabled?: boolean; + arrived?: boolean; + hasBound?: boolean; + untouchableModeEnabled?: boolean; +}; + +const buildStore = ({ + portraitModeEnabled = false, + arrived = true, + hasBound = true, + untouchableModeEnabled = false, +}: Options = {}) => { + const store = createStore(); + store.set(portraitModeEnabledAtom, portraitModeEnabled); + store.set(arrivedAtom, arrived); + store.set( + selectedBoundAtom, + hasBound ? ({ id: 1, name: '東京' } as never) : null + ); + store.set(tuningState, (prev) => ({ ...prev, untouchableModeEnabled })); + return store; +}; + +const renderPromo = (store: ReturnType) => + renderHook(() => usePortraitModePromo(), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + }); + +const holdPortrait = () => { + act(() => { + jest.advanceTimersByTime(PORTRAIT_HOLD_DURATION_MS); + }); +}; + +describe('usePortraitModePromo', () => { + beforeEach(() => { + jest.useFakeTimers(); + storage.clearAll(); + mockDimensions.width = 360; + mockDimensions.height = 780; + }); + + afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + it('縦持ちが一定時間続いたら表示する', () => { + const { result } = renderPromo(buildStore()); + + expect(result.current.visible).toBe(false); + + holdPortrait(); + + expect(result.current.visible).toBe(true); + }); + + it('端末が横向きの間は表示しない', () => { + mockDimensions.width = 780; + mockDimensions.height = 360; + + const { result } = renderPromo(buildStore()); + holdPortrait(); + + expect(result.current.visible).toBe(false); + }); + + it('走行中(未到着)は表示しない', () => { + const { result } = renderPromo(buildStore({ arrived: false })); + holdPortrait(); + + expect(result.current.visible).toBe(false); + }); + + it('行先未選択のときは表示しない', () => { + const { result } = renderPromo(buildStore({ hasBound: false })); + holdPortrait(); + + expect(result.current.visible).toBe(false); + }); + + it('無操作モード中は表示しない', () => { + const { result } = renderPromo( + buildStore({ untouchableModeEnabled: true }) + ); + holdPortrait(); + + expect(result.current.visible).toBe(false); + }); + + it('すでにポートレートモードが有効なら表示しない', () => { + const { result } = renderPromo(buildStore({ portraitModeEnabled: true })); + holdPortrait(); + + expect(result.current.visible).toBe(false); + }); + + it('上限まで提示済みなら表示しない', () => { + for (let i = 0; i < PORTRAIT_PROMPT_MAX_COUNT; i++) { + recordPortraitPromptDismissed(i); + } + + const { result } = renderPromo(buildStore()); + holdPortrait(); + + expect(result.current.visible).toBe(false); + }); + + it('「オンにする」で設定を保存し、以降の訴求を打ち切る', () => { + const store = buildStore(); + const { result } = renderPromo(store); + holdPortrait(); + + act(() => { + result.current.enable(); + }); + + expect(result.current.visible).toBe(false); + expect(store.get(portraitModeEnabledAtom)).toBe(true); + expect(storage.getString(STORAGE_KEYS.PORTRAIT_MODE_ENABLED)).toBe('true'); + expect(isPortraitPromoFinished()).toBe(true); + }); + + it('「今はしない」で閉じると提示回数を記録する', () => { + const store = buildStore(); + const { result } = renderPromo(store); + holdPortrait(); + + act(() => { + result.current.dismiss(); + }); + + expect(result.current.visible).toBe(false); + expect(store.get(portraitModeEnabledAtom)).toBe(false); + expect(storage.getString(STORAGE_KEYS.PORTRAIT_PROMO_PROMPT_COUNT)).toBe( + '1' + ); + expect(isPortraitPromoFinished()).toBe(false); + }); +}); diff --git a/src/hooks/usePortraitModePromo.ts b/src/hooks/usePortraitModePromo.ts new file mode 100644 index 0000000000..141502deca --- /dev/null +++ b/src/hooks/usePortraitModePromo.ts @@ -0,0 +1,98 @@ +import { useAtom, useAtomValue } from 'jotai'; +import { useCallback, useEffect, useState } from 'react'; +import { useWindowDimensions } from 'react-native'; +import { STORAGE_KEYS } from '~/constants/storage'; +import { storage } from '~/lib/storage'; +import { + portraitModeEnabledAtom, + portraitPromoFinishedAtom, +} from '~/store/atoms/display'; +import { arrivedAtom, selectedBoundAtom } from '~/store/atoms/station'; +import { untouchableModeEnabledAtom } from '~/store/atoms/tuning'; +import { translate } from '~/translation'; +import { showDialog } from '~/utils/dialogPresentation'; +import { + canShowPortraitPrompt, + finishPortraitPromo, + recordPortraitPromptDismissed, +} from '~/utils/portraitPromo'; + +/** この時間だけ縦持ちが続いたら「縦で見たい」とみなす */ +export const PORTRAIT_HOLD_DURATION_MS = 3000; + +type UsePortraitModePromoResult = { + visible: boolean; + enable: () => void; + dismiss: () => void; +}; + +/** + * 走行画面でポートレートモードを訴求するかどうかを決める(案A)。 + * + * ポートレートモードが無効なまま端末を縦にすると、走行画面は中身を90度回転して + * 横長のまま表示される(Main の landscapeKeepStyle)。つまり「縦で見たい人」は + * この状態で検知できるので、そこだけを狙って一度だけ提案する。 + * + * 走行中の割り込みを最小にするため、停車中(arrived)に限り、無操作モード中は出さない。 + */ +export const usePortraitModePromo = (): UsePortraitModePromoResult => { + const [portraitModeEnabled, setPortraitModeEnabled] = useAtom( + portraitModeEnabledAtom + ); + const arrived = useAtomValue(arrivedAtom); + const selectedBound = useAtomValue(selectedBoundAtom); + const untouchableModeEnabled = useAtomValue(untouchableModeEnabledAtom); + const [promoFinished, setPromoFinished] = useAtom(portraitPromoFinishedAtom); + const { width, height } = useWindowDimensions(); + + // MMKV は同期 API なので初回レンダー時に提示可否が確定する + const [eligible] = useState(() => canShowPortraitPrompt()); + const [held, setHeld] = useState(false); + const [closed, setClosed] = useState(false); + + const isPortrait = height > width; + const conditionsMet = + eligible && + !promoFinished && + !closed && + !portraitModeEnabled && + !untouchableModeEnabled && + !!selectedBound && + arrived && + isPortrait; + + useEffect(() => { + if (held || !conditionsMet) { + return; + } + const timerId = setTimeout(() => setHeld(true), PORTRAIT_HOLD_DURATION_MS); + return () => clearTimeout(timerId); + }, [conditionsMet, held]); + + // 一度出したあとは発車しても引っ込めない(読んでいる途中で消えると不親切)。 + // ただし横に戻したら隠す。縦に戻せばまた出る。 + const visible = held && !closed && !portraitModeEnabled && isPortrait; + + const enable = useCallback(() => { + setClosed(true); + setPortraitModeEnabled(true); + try { + storage.set(STORAGE_KEYS.PORTRAIT_MODE_ENABLED, 'true'); + finishPortraitPromo(); + setPromoFinished(true); + } catch (error) { + // 保存に失敗したままだと次回起動時に設定が巻き戻るため、 + // UIと永続値の不整合を防ぐべくatom状態をロールバックする + setPortraitModeEnabled(false); + console.error('Failed to save portrait mode setting', error); + showDialog(translate('errorTitle'), translate('failedToSavePreference')); + } + }, [setPortraitModeEnabled, setPromoFinished]); + + const dismiss = useCallback(() => { + setClosed(true); + recordPortraitPromptDismissed(); + }, []); + + return { visible, enable, dismiss }; +}; diff --git a/src/hooks/usePortraitPromoAppearanceHint.test.tsx b/src/hooks/usePortraitPromoAppearanceHint.test.tsx new file mode 100644 index 0000000000..0e64ed616c --- /dev/null +++ b/src/hooks/usePortraitPromoAppearanceHint.test.tsx @@ -0,0 +1,88 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { createStore, Provider } from 'jotai'; +import type React from 'react'; +import { storage } from '~/lib/storage'; +import { + portraitModeEnabledAtom, + portraitPromoAppearanceSeenAtom, + portraitPromoFinishedAtom, +} from '~/store/atoms/display'; +import { usePortraitPromoAppearanceHint } from './usePortraitPromoAppearanceHint'; + +const renderHint = (store: ReturnType) => + renderHook(() => usePortraitPromoAppearanceHint(), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + }); + +const buildStore = ({ + appearanceSeen = false, + portraitModeEnabled = false, + finished = false, +} = {}) => { + const store = createStore(); + store.set(portraitPromoAppearanceSeenAtom, appearanceSeen); + store.set(portraitModeEnabledAtom, portraitModeEnabled); + store.set(portraitPromoFinishedAtom, finished); + return store; +}; + +describe('usePortraitPromoAppearanceHint', () => { + beforeEach(() => { + storage.clearAll(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('未読かつ無効なら印を出す', () => { + const { result } = renderHint(buildStore()); + + expect(result.current).toBe(true); + }); + + it('外観画面を開いたら印を消す(再マウントを待たずに反映する)', () => { + const store = buildStore(); + const { result } = renderHint(store); + + expect(result.current).toBe(true); + + act(() => { + store.set(portraitPromoAppearanceSeenAtom, true); + }); + + expect(result.current).toBe(false); + }); + + it('ポートレートモードを有効にしたら印を消す', () => { + const store = buildStore(); + const { result } = renderHint(store); + + act(() => { + store.set(portraitModeEnabledAtom, true); + }); + + expect(result.current).toBe(false); + }); + + it('訴求を打ち切ったあとは印を出さない', () => { + const { result } = renderHint(buildStore({ finished: true })); + + expect(result.current).toBe(false); + }); + + it('一度オンにしたあとオフに戻されても印は復活しない', () => { + const store = buildStore({ finished: true, portraitModeEnabled: true }); + const { result } = renderHint(store); + + expect(result.current).toBe(false); + + act(() => { + store.set(portraitModeEnabledAtom, false); + }); + + expect(result.current).toBe(false); + }); +}); diff --git a/src/hooks/usePortraitPromoAppearanceHint.ts b/src/hooks/usePortraitPromoAppearanceHint.ts new file mode 100644 index 0000000000..9bcc6cf1e2 --- /dev/null +++ b/src/hooks/usePortraitPromoAppearanceHint.ts @@ -0,0 +1,25 @@ +import { useAtomValue } from 'jotai'; +import { + portraitModeEnabledAtom, + portraitPromoAppearanceSeenAtom, + portraitPromoFinishedAtom, +} from '~/store/atoms/display'; + +/** + * 設定リストの印とフッタータブのドット(案C)を出すかどうか。 + * + * 「外観画面を開いたか」は atom で購読する。印を出す画面(AppSettings / + * FooterTabBar)は外観画面から戻ってきても再マウントされないため、 + * マウント時のスナップショットだと開いたあとも印が残る。 + * + * 外観画面のスポットライトはこのフックを使わないこと。開いた時点で既読になる以上、 + * リアクティブに読むと自分自身を即座に閉じてしまう。そちらは + * canShowPortraitAppearanceHint() でマウント時のスナップショットを取る。 + */ +export const usePortraitPromoAppearanceHint = (): boolean => { + const finished = useAtomValue(portraitPromoFinishedAtom); + const appearanceSeen = useAtomValue(portraitPromoAppearanceSeenAtom); + const portraitModeEnabled = useAtomValue(portraitModeEnabledAtom); + + return !finished && !appearanceSeen && !portraitModeEnabled; +}; diff --git a/src/hooks/useSettingsWalkthrough.test.tsx b/src/hooks/useSettingsWalkthrough.test.tsx new file mode 100644 index 0000000000..657cb14295 --- /dev/null +++ b/src/hooks/useSettingsWalkthrough.test.tsx @@ -0,0 +1,58 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { STORAGE_KEYS } from '~/constants/storage'; +import { storage } from '~/lib/storage'; +import { useSettingsWalkthrough } from './useSettingsWalkthrough'; + +describe('useSettingsWalkthrough', () => { + beforeEach(() => { + storage.clearAll(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('設定リストの行と同じ順にスポットライトが進む', () => { + const { result } = renderHook(() => useSettingsWalkthrough()); + + const visited = [result.current.currentStepId]; + for (let i = 1; i < result.current.totalSteps; i++) { + act(() => { + result.current.nextStep(); + }); + visited.push(result.current.currentStepId); + } + + expect(visited).toEqual([ + 'settingsWelcome', + 'settingsTheme', + 'settingsColorScheme', + 'settingsTts', + 'settingsLanguages', + ]); + }); + + it('最後まで進めるとウォークスルーが完了する', () => { + const { result } = renderHook(() => useSettingsWalkthrough()); + + for (let i = 0; i < result.current.totalSteps; i++) { + act(() => { + result.current.nextStep(); + }); + } + + expect(result.current.isWalkthroughCompleted).toBe(true); + expect(result.current.isWalkthroughActive).toBe(false); + expect(storage.getString(STORAGE_KEYS.SETTINGS_WALKTHROUGH_COMPLETED)).toBe( + 'true' + ); + }); + + it('完了済みならウォークスルーは起動しない', () => { + storage.set(STORAGE_KEYS.SETTINGS_WALKTHROUGH_COMPLETED, 'true'); + + const { result } = renderHook(() => useSettingsWalkthrough()); + + expect(result.current.isWalkthroughActive).toBe(false); + }); +}); diff --git a/src/hooks/useSettingsWalkthrough.ts b/src/hooks/useSettingsWalkthrough.ts index 4bfeead04d..effa165747 100644 --- a/src/hooks/useSettingsWalkthrough.ts +++ b/src/hooks/useSettingsWalkthrough.ts @@ -19,6 +19,12 @@ const SETTINGS_WALKTHROUGH_STEPS: WalkthroughStep[] = [ descriptionKey: 'settingsWalkthroughDescription2', tooltipPosition: 'bottom', }, + { + id: 'settingsColorScheme', + titleKey: 'settingsWalkthroughColorSchemeTitle', + descriptionKey: 'settingsWalkthroughColorSchemeDescription', + tooltipPosition: 'bottom', + }, { id: 'settingsTts', titleKey: 'settingsWalkthroughTitle3', diff --git a/src/hooks/useTTSText.ts b/src/hooks/useTTSText.ts index dfb54c58f7..644f6d0dcb 100644 --- a/src/hooks/useTTSText.ts +++ b/src/hooks/useTTSText.ts @@ -44,7 +44,11 @@ export interface TTSTextResult { } const resolveTemplateTheme = (theme: AppTheme): AppTheme => { - if (theme === APP_THEME.LED || theme === APP_THEME.ODAKYU) + if ( + theme === APP_THEME.LED || + theme === APP_THEME.ODAKYU || + theme === APP_THEME.LOW_POWER + ) return APP_THEME.TOKYO_METRO; if ( theme === APP_THEME.JO || diff --git a/src/hooks/useTransferLines.ts b/src/hooks/useTransferLines.ts index ca0f6d1779..f3eacf2748 100644 --- a/src/hooks/useTransferLines.ts +++ b/src/hooks/useTransferLines.ts @@ -1,11 +1,6 @@ -import { useAtomValue } from 'jotai'; -import { useMemo } from 'react'; import type { Line } from '~/@types/graphql'; -import { arrivedAtom } from '../store/atoms/station'; -import getIsPass from '../utils/isPass'; -import { useCurrentStation } from './useCurrentStation'; -import { useDisplayNextStation } from './useDisplayNextStation'; import { useTransferLinesFromStation } from './useTransferLinesFromStation'; +import { useTransferTargetStation } from './useTransferTargetStation'; type Option = { omitRepeatingLine?: boolean; @@ -13,20 +8,7 @@ type Option = { }; export const useTransferLines = (options?: Option): Line[] => { - const arrived = useAtomValue(arrivedAtom); - const currentStation = useCurrentStation(false, true); - // ヘッダー・TTS が「まもなく」で読み上げる駅 (接近中はGPS基準の接近駅) と - // 乗換案内の対象駅を一致させる。useNextStation 起点のままだと、到着判定の - // 取りこぼしで stationState が古い場合に「まもなくA、B駅の乗換路線をご案内」 - // という不整合が起きる。 - const nextStation = useDisplayNextStation(); - const targetStation = useMemo( - () => - arrived && currentStation && !getIsPass(currentStation) - ? currentStation - : nextStation, - [arrived, currentStation, nextStation] - ); + const targetStation = useTransferTargetStation(); const { omitRepeatingLine, omitJR } = options ?? { omitRepeatingLine: false, diff --git a/src/hooks/useTransferStationNumbers.ts b/src/hooks/useTransferStationNumbers.ts new file mode 100644 index 0000000000..a4582578a0 --- /dev/null +++ b/src/hooks/useTransferStationNumbers.ts @@ -0,0 +1,55 @@ +import { useMemo } from 'react'; +import type { Line } from '~/@types/graphql'; + +/** + * 乗換路線ごとに、その路線のシンボルと対応する駅ナンバリングを取り出す。 + * + * 駅側の stationNumbers に路線のシンボルと一致するものが無い場合は、 + * シンボルを持たない駅ナンバリングと路線側の先頭シンボルを組み合わせて + * フォールバックする(シンボル未設定の路線でもナンバリングを出すため)。 + * どちらの経路でも lineSymbolShape は 'NOOP' で埋めて型を揃える。 + */ +export const useTransferStationNumbers = (lines: Line[]) => + useMemo( + () => + lines.map((l) => { + const stationNumberData = l.station?.stationNumbers?.find((sn) => + l.lineSymbols?.some((sym) => sym.symbol === sn.lineSymbol) + ); + const lineSymbol = stationNumberData?.lineSymbol ?? ''; + const lineSymbolColor = stationNumberData?.lineSymbolColor ?? ''; + const stationNumber = stationNumberData?.stationNumber ?? ''; + const lineSymbolShape = stationNumberData?.lineSymbolShape ?? 'NOOP'; + + if (!lineSymbol.length || !stationNumber.length) { + const stationNumberWhenEmptySymbol = + l.station?.stationNumbers?.find((sn) => !sn.lineSymbol?.length) + ?.stationNumber ?? ''; + const lineSymbolWhenEmptySymbol = l.lineSymbols?.[0]?.symbol ?? ''; + const lineSymbolColorWhenEmptySymbol = + l.station?.stationNumbers?.find((sn) => !sn.lineSymbol?.length) + ?.lineSymbolColor ?? '#000000'; + const lineSymbolShapeWhenEmptySymbol = + l.station?.stationNumbers?.find( + (sn) => !sn.lineSymbol?.length + )?.lineSymbolShape; + + return { + __typename: 'StationNumber' as const, + lineSymbol: lineSymbolWhenEmptySymbol, + lineSymbolColor: lineSymbolColorWhenEmptySymbol, + stationNumber: stationNumberWhenEmptySymbol, + lineSymbolShape: lineSymbolShapeWhenEmptySymbol ?? 'NOOP', + }; + } + + return { + __typename: 'StationNumber' as const, + lineSymbol, + lineSymbolColor, + stationNumber, + lineSymbolShape, + }; + }), + [lines] + ); diff --git a/src/hooks/useTransferTargetStation.test.tsx b/src/hooks/useTransferTargetStation.test.tsx new file mode 100644 index 0000000000..b15e5ee14d --- /dev/null +++ b/src/hooks/useTransferTargetStation.test.tsx @@ -0,0 +1,96 @@ +import { renderHook } from '@testing-library/react-native'; +import { createStore, Provider } from 'jotai'; +import type React from 'react'; +import { type Station, StopCondition } from '~/@types/graphql'; +import { arrivedAtom } from '~/store/atoms/station'; +import { useCurrentStation } from './useCurrentStation'; +import { useDisplayNextStation } from './useDisplayNextStation'; +import { useTransferTargetStation } from './useTransferTargetStation'; + +jest.mock('./useCurrentStation', () => ({ + useCurrentStation: jest.fn(), +})); +jest.mock('./useDisplayNextStation', () => ({ + useDisplayNextStation: jest.fn(), +})); + +const mockedUseCurrentStation = useCurrentStation as jest.Mock; +const mockedUseDisplayNextStation = useDisplayNextStation as jest.Mock; + +const buildStation = ( + id: number, + name: string, + stopCondition: StopCondition +): Station => ({ id, groupId: id, name, stopCondition }) as unknown as Station; + +const shinjuku = buildStation(1, '新宿', StopCondition.All); +const koenji = buildStation(2, '高円寺', StopCondition.Not); +const nakano = buildStation(3, '中野', StopCondition.All); + +const renderWith = ({ + arrived, + currentStation, + nextStation, +}: { + arrived: boolean; + currentStation: Station | undefined; + nextStation: Station | undefined; +}) => { + const store = createStore(); + store.set(arrivedAtom, arrived); + mockedUseCurrentStation.mockReturnValue(currentStation); + mockedUseDisplayNextStation.mockReturnValue(nextStation); + + return renderHook(() => useTransferTargetStation(), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + }); +}; + +describe('useTransferTargetStation', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('停車中で現在駅が停車駅なら現在駅を対象にする', () => { + const { result } = renderWith({ + arrived: true, + currentStation: shinjuku, + nextStation: nakano, + }); + + expect(result.current).toBe(shinjuku); + }); + + it('到着扱いでも現在駅が通過駅なら次の駅を対象にする', () => { + // 通過中の駅の乗換路線を案内してしまわないこと + const { result } = renderWith({ + arrived: true, + currentStation: koenji, + nextStation: nakano, + }); + + expect(result.current).toBe(nakano); + }); + + it('未到着なら表示用の次駅(接近中はGPS基準の接近駅)を対象にする', () => { + const { result } = renderWith({ + arrived: false, + currentStation: shinjuku, + nextStation: nakano, + }); + + expect(result.current).toBe(nakano); + }); + + it('現在駅が取れないときは次の駅へ倒す', () => { + const { result } = renderWith({ + arrived: true, + currentStation: undefined, + nextStation: nakano, + }); + + expect(result.current).toBe(nakano); + }); +}); diff --git a/src/hooks/useTransferTargetStation.ts b/src/hooks/useTransferTargetStation.ts new file mode 100644 index 0000000000..60ed8a3d25 --- /dev/null +++ b/src/hooks/useTransferTargetStation.ts @@ -0,0 +1,29 @@ +import { useAtomValue } from 'jotai'; +import { useMemo } from 'react'; +import type { Station } from '~/@types/graphql'; +import { arrivedAtom } from '../store/atoms/station'; +import getIsPass from '../utils/isPass'; +import { useCurrentStation } from './useCurrentStation'; +import { useDisplayNextStation } from './useDisplayNextStation'; + +/** + * 乗換案内の対象駅。 + * + * ヘッダー・TTS が「まもなく」で読み上げる駅 (接近中はGPS基準の接近駅) と + * 乗換案内の対象駅を一致させる。useNextStation 起点のままだと、到着判定の + * 取りこぼしで stationState が古い場合に「まもなくA、B駅の乗換路線をご案内」 + * という不整合が起きる。 + */ +export const useTransferTargetStation = (): Station | undefined => { + const arrived = useAtomValue(arrivedAtom); + const currentStation = useCurrentStation(false, true); + const nextStation = useDisplayNextStation(); + + return useMemo( + () => + arrived && currentStation && !getIsPass(currentStation) + ? currentStation + : nextStation, + [arrived, currentStation, nextStation] + ); +}; diff --git a/src/hooks/useTrimMemoryOnBackground.test.ts b/src/hooks/useTrimMemoryOnBackground.test.ts new file mode 100644 index 0000000000..ac22d6682c --- /dev/null +++ b/src/hooks/useTrimMemoryOnBackground.test.ts @@ -0,0 +1,69 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { Image } from 'expo-image'; +import { AppState, type AppStateStatus } from 'react-native'; +import { useTrimMemoryOnBackground } from './useTrimMemoryOnBackground'; + +jest.mock('expo-image', () => ({ + Image: { clearMemoryCache: jest.fn(() => Promise.resolve(true)) }, +})); + +const clearMemoryCache = Image.clearMemoryCache as jest.Mock; + +let listener: ((state: AppStateStatus) => void) | undefined; +const remove = jest.fn(); + +beforeEach(() => { + listener = undefined; + jest + .spyOn(AppState, 'addEventListener') + .mockImplementation((_type, handler) => { + listener = handler as (state: AppStateStatus) => void; + return { remove } as ReturnType; + }); +}); + +afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); +}); + +const emit = (state: AppStateStatus) => + act(() => { + listener?.(state); + }); + +describe('useTrimMemoryOnBackground', () => { + it("'background' でメモリキャッシュを破棄する", () => { + renderHook(() => useTrimMemoryOnBackground()); + + emit('background'); + + expect(clearMemoryCache).toHaveBeenCalledTimes(1); + }); + + it("'active' と 'inactive' では破棄しない", () => { + renderHook(() => useTrimMemoryOnBackground()); + + // 'inactive' は iOS のアプリスイッチャー表示などで頻発し、すぐ 'active' に戻る。 + // ここで破棄すると復帰のたびに再デコードが走る + emit('inactive'); + emit('active'); + + expect(clearMemoryCache).not.toHaveBeenCalled(); + }); + + it('clearMemoryCache の失敗を握りつぶす', () => { + clearMemoryCache.mockRejectedValueOnce(new Error('failed')); + renderHook(() => useTrimMemoryOnBackground()); + + expect(() => emit('background')).not.toThrow(); + }); + + it('アンマウントでリスナーを解除する', () => { + const { unmount } = renderHook(() => useTrimMemoryOnBackground()); + + unmount(); + + expect(remove).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/hooks/useTrimMemoryOnBackground.ts b/src/hooks/useTrimMemoryOnBackground.ts new file mode 100644 index 0000000000..b52d3f62b0 --- /dev/null +++ b/src/hooks/useTrimMemoryOnBackground.ts @@ -0,0 +1,32 @@ +import { Image } from 'expo-image'; +import { useEffect } from 'react'; +import { AppState, type AppStateStatus } from 'react-native'; + +// expo-image のメモリキャッシュをバックグラウンド遷移時に破棄する。 +// +// 本アプリは乗車中、位置情報フォアグラウンドサービスによってプロセスを常駐させる +// (useStartBackgroundLocationUpdates 参照)。そのため画面が見えていない間もデコード済み +// ビットマップが Glide のメモリキャッシュに残り続け、退去する契機がない。 +// Google Play の技術品質要件 (2027年2月施行) は user-perceived service / background 状態で +// 200MB、cached 状態で 400MB のビットマップ使用量を上限としており、テーマプレビューのような +// 大判画像が滞留するとこれに抵触しうる。 +// +// ディスクキャッシュは残す。次回表示のデコードが走るだけで、メモリ使用量には影響しないため。 +export const useTrimMemoryOnBackground = (): void => { + useEffect(() => { + const handleChange = (state: AppStateStatus): void => { + // 'inactive' は iOS のアプリスイッチャー表示などで頻発し、すぐ 'active' へ戻る。 + // ここで破棄すると復帰のたびに再デコードが走るため 'background' のみを対象にする。 + if (state !== 'background') { + return; + } + Image.clearMemoryCache().catch(() => {}); + }; + + const sub = AppState.addEventListener('change', handleChange); + + return () => { + sub.remove(); + }; + }, []); +}; diff --git a/src/hooks/useUpdateBottomState.test.tsx b/src/hooks/useUpdateBottomState.test.tsx new file mode 100644 index 0000000000..c70c75b682 --- /dev/null +++ b/src/hooks/useUpdateBottomState.test.tsx @@ -0,0 +1,124 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { createStore, Provider } from 'jotai'; +import type React from 'react'; +import { DEFAULT_BOTTOM_TRANSITION_INTERVAL } from '~/constants'; +import { THEME_PREFERENCE, type ThemePreference } from '~/models/Theme'; +import { portraitModeEnabledAtom } from '~/store/atoms/display'; +import { bottomStateAtom } from '~/store/atoms/navigation'; +import { themePreferenceAtom } from '~/store/atoms/theme'; +import { useUpdateBottomState } from './useUpdateBottomState'; + +// 乗換路線が1件でもあれば LINE -> TRANSFER へ進む +jest.mock('./useTransferLines', () => ({ + useTransferLines: () => [{ id: 1 }], +})); +jest.mock('./useTypeWillChange', () => ({ + useTypeWillChange: () => false, +})); +jest.mock('./useShouldHideTypeChange', () => ({ + useShouldHideTypeChange: () => false, +})); + +const mockLayout = { isPortrait: false }; +jest.mock('./useLandscapeWindowDimensions', () => ({ + useLandscapeWindowDimensions: () => ({ + width: 800, + height: 400, + isPortrait: mockLayout.isPortrait, + }), +})); + +const renderWithStore = (store: ReturnType) => + renderHook(() => useUpdateBottomState(), { + wrapper: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + }); + +const buildStore = ({ + theme, + portraitModeEnabled, + isPortrait, +}: { + theme: ThemePreference; + portraitModeEnabled: boolean; + isPortrait: boolean; +}) => { + const store = createStore(); + store.set(themePreferenceAtom, theme); + store.set(portraitModeEnabledAtom, portraitModeEnabled); + mockLayout.isPortrait = isPortrait; + return store; +}; + +const advanceOneInterval = () => { + act(() => { + jest.advanceTimersByTime(DEFAULT_BOTTOM_TRANSITION_INTERVAL); + }); +}; + +describe('useUpdateBottomState', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.useRealTimers(); + mockLayout.isPortrait = false; + }); + + it('通常のテーマでは一定間隔でのりかえ案内へ進む', () => { + const store = buildStore({ + theme: THEME_PREFERENCE.TOKYO_METRO, + portraitModeEnabled: false, + isPortrait: false, + }); + renderWithStore(store); + + advanceOneInterval(); + + expect(store.get(bottomStateAtom)).toBe('TRANSFER'); + }); + + it('横画面の電光掲示板風テーマは下部の領域を持たないので切り替えない', () => { + const store = buildStore({ + theme: THEME_PREFERENCE.LED, + portraitModeEnabled: false, + isPortrait: false, + }); + renderWithStore(store); + + advanceOneInterval(); + + expect(store.get(bottomStateAtom)).toBe('LINE'); + }); + + it('ポートレートレイアウト中は電光掲示板風テーマでも切り替える', () => { + // ポートレートは路線テーマに依存しない独自の画面なので、 + // 電光掲示板風テーマを選んでいてものりかえ案内は出す + const store = buildStore({ + theme: THEME_PREFERENCE.LED, + portraitModeEnabled: true, + isPortrait: true, + }); + renderWithStore(store); + + advanceOneInterval(); + + expect(store.get(bottomStateAtom)).toBe('TRANSFER'); + }); + + it('ポートレートモードが有効でも端末が横向きなら従来どおり切り替えない', () => { + const store = buildStore({ + theme: THEME_PREFERENCE.LED, + portraitModeEnabled: true, + isPortrait: false, + }); + renderWithStore(store); + + advanceOneInterval(); + + expect(store.get(bottomStateAtom)).toBe('LINE'); + }); +}); diff --git a/src/hooks/useUpdateBottomState.ts b/src/hooks/useUpdateBottomState.ts index 0c736e6b09..8f07fbbb4f 100644 --- a/src/hooks/useUpdateBottomState.ts +++ b/src/hooks/useUpdateBottomState.ts @@ -1,9 +1,11 @@ import { useAtomValue, useSetAtom } from 'jotai'; import { useCallback, useEffect } from 'react'; +import { portraitModeEnabledAtom } from '../store/atoms/display'; import navigationState, { bottomStateAtom } from '../store/atoms/navigation'; import { isLEDThemeAtom } from '../store/atoms/theme'; import tuningState from '../store/atoms/tuning'; import { useInterval } from './useInterval'; +import { useLandscapeWindowDimensions } from './useLandscapeWindowDimensions'; import { useShouldHideTypeChange } from './useShouldHideTypeChange'; import { useTransferLines } from './useTransferLines'; import { useTypeWillChange } from './useTypeWillChange'; @@ -15,11 +17,18 @@ export const useUpdateBottomState = () => { const { bottomTransitionInterval } = useAtomValue(tuningState); const bottomStateRef = useValueRef(bottomState); const isLEDTheme = useAtomValue(isLEDThemeAtom); + const portraitModeEnabled = useAtomValue(portraitModeEnabledAtom); + const { isPortrait } = useLandscapeWindowDimensions(); + // ポートレートレイアウトは路線テーマに依存しない独自の画面なので、電光掲示板風 + // テーマを選んでいても下部の表示は切り替える。横画面の電光掲示板風テーマは + // 下部の領域自体を持たないため、そちらは従来どおり止めたままにする。 + const isPortraitLayout = portraitModeEnabled && isPortrait; const isTypeWillChange = useTypeWillChange(); const isTypeWillChangeRef = useValueRef(isTypeWillChange); const transferLines = useTransferLines(); const isLEDThemeRef = useValueRef(isLEDTheme); + const isPortraitLayoutRef = useValueRef(isPortraitLayout); const shouldHideTypeChange = useShouldHideTypeChange(); const shouldHideTypeChangeRef = useValueRef(shouldHideTypeChange); @@ -31,7 +40,7 @@ export const useUpdateBottomState = () => { const { pause } = useInterval( useCallback(() => { - if (isLEDThemeRef.current) { + if (isLEDThemeRef.current && !isPortraitLayoutRef.current) { return; } @@ -75,6 +84,7 @@ export const useUpdateBottomState = () => { bottomStateRef, isTypeWillChangeRef, isLEDThemeRef, + isPortraitLayoutRef, shouldHideTypeChangeRef, setNavigation, transferLines.length, diff --git a/src/index.tsx b/src/index.tsx index d623227cb4..14f8a4419f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -18,6 +18,7 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler'; import CommonDialogPresenter from './components/CommonDialogPresenter'; import CustomErrorBoundary from './components/CustomErrorBoundary'; import FxSystemColorScheme from './components/FxSystemColorScheme'; +import FxTrimMemoryOnBackground from './components/FxTrimMemoryOnBackground'; import { GlobalToast } from './components/GlobalToast'; import { queryClient } from './lib/gql'; import { migrateFromAsyncStorage } from './lib/storage'; @@ -155,6 +156,8 @@ const App: React.FC = () => { {/* 端末のダークモード設定を購読して atom へ反映する */} + {/* バックグラウンド遷移時に画像のメモリキャッシュを破棄する */} + {/* 画面をまたいで呼ばれる showDialog を一つの共通モーダルとして描画する。 */} diff --git a/src/models/Theme.ts b/src/models/Theme.ts index 549a902baa..7841b5d7ee 100644 --- a/src/models/Theme.ts +++ b/src/models/Theme.ts @@ -11,6 +11,7 @@ export const APP_THEME = { JR_KYUSHU: 'JR_KYUSHU', ODAKYU: 'ODAKYU', E231: 'E231', + LOW_POWER: 'LOW_POWER', } as const; export type AppTheme = (typeof APP_THEME)[keyof typeof APP_THEME]; diff --git a/src/screens/AppSettings.test.tsx b/src/screens/AppSettings.test.tsx index ea31b8a5ee..db1f63dc99 100644 --- a/src/screens/AppSettings.test.tsx +++ b/src/screens/AppSettings.test.tsx @@ -69,7 +69,7 @@ jest.mock('~/hooks/useSettingsWalkthrough', () => ({ currentStepIndex: 0, currentStepId: mockCurrentStepId, currentStep: null, - totalSteps: 4, + totalSteps: 5, nextStep: jest.fn(), goToStep: jest.fn(), skipWalkthrough: jest.fn(), @@ -87,6 +87,7 @@ const lastSpotlightArea = (): WalkthroughStep['spotlightArea'] => { // スポットライト対象の項目を持つステップ const SPOTLIGHT_STEP_IDS: WalkthroughStepId[] = [ 'settingsTheme', + 'settingsColorScheme', 'settingsTts', 'settingsLanguages', ]; diff --git a/src/screens/AppSettings.tsx b/src/screens/AppSettings.tsx index e493eec8ea..a501cf6e03 100644 --- a/src/screens/AppSettings.tsx +++ b/src/screens/AppSettings.tsx @@ -21,9 +21,11 @@ import { isClip } from 'react-native-app-clip'; import { SafeAreaView } from 'react-native-safe-area-context'; import { CardChevron } from '~/components/CardChevron'; import { Heading } from '~/components/Heading'; +import NewFeatureDot from '~/components/NewFeatureDot'; import { SettingsHeader } from '~/components/SettingsHeader'; import Typography from '~/components/Typography'; import WalkthroughOverlay from '~/components/WalkthroughOverlay'; +import { usePortraitPromoAppearanceHint } from '~/hooks/usePortraitPromoAppearanceHint'; import { useSettingsWalkthrough } from '~/hooks/useSettingsWalkthrough'; import { useAppColors } from '~/providers/AppColorsProvider'; import { isBetaBuild } from '~/utils/isBetaBuild'; @@ -91,11 +93,14 @@ const SettingsItem = ({ isFirst, isLast, onPress, + showNewFeatureDot, }: { item: SettingsSectionData; isFirst: boolean; isLast: boolean; onPress?: () => void; + /** 新機能の在り処を示す印。シェブロンの手前に置く */ + showNewFeatureDot?: boolean; }) => { const isLEDTheme = useAtomValue(isLEDThemeAtom); const colors = useAppColors(); @@ -172,6 +177,12 @@ const SettingsItem = ({ + {showNewFeatureDot ? ( + + + + ) : null} + ); @@ -182,6 +193,8 @@ const AppSettingsScreen: React.FC = () => { const [themeItemLayout, setThemeItemLayout] = useState( null ); + const [colorSchemeItemLayout, setColorSchemeItemLayout] = + useState(null); const [ttsItemLayout, setTtsItemLayout] = useState(null); const [languagesItemLayout, setLanguagesItemLayout] = useState(null); @@ -192,8 +205,10 @@ const AppSettingsScreen: React.FC = () => { const isLEDTheme = useAtomValue(isLEDThemeAtom); const colors = useAppColors(); const navigation = useNavigation(); + const showPortraitPromoHint = usePortraitPromoAppearanceHint(); const themeRef = useRef(null); + const colorSchemeRef = useRef(null); const ttsRef = useRef(null); const languagesRef = useRef(null); @@ -219,6 +234,16 @@ const AppSettingsScreen: React.FC = () => { } }, []); + const handleColorSchemeLayout = useCallback(() => { + if (colorSchemeRef.current) { + colorSchemeRef.current.measureInWindow( + (x: number, y: number, width: number, height: number) => { + setColorSchemeItemLayout({ x, y, width, height }); + } + ); + } + }, []); + const handleTtsLayout = useCallback(() => { if (ttsRef.current) { ttsRef.current.measureInWindow( @@ -245,11 +270,18 @@ const AppSettingsScreen: React.FC = () => { // Use requestAnimationFrame to ensure layout has been applied requestAnimationFrame(() => { handleThemeLayout(); + handleColorSchemeLayout(); handleTtsLayout(); handleLanguagesLayout(); }); } - }, [headerHeight, handleThemeLayout, handleTtsLayout, handleLanguagesLayout]); + }, [ + headerHeight, + handleThemeLayout, + handleColorSchemeLayout, + handleTtsLayout, + handleLanguagesLayout, + ]); useEffect(() => { if (currentStepId === 'settingsTheme' && themeItemLayout) { @@ -263,6 +295,18 @@ const AppSettingsScreen: React.FC = () => { } }, [currentStepId, themeItemLayout, setSpotlightArea]); + useEffect(() => { + if (currentStepId === 'settingsColorScheme' && colorSchemeItemLayout) { + setSpotlightArea({ + x: colorSchemeItemLayout.x, + y: colorSchemeItemLayout.y, + width: colorSchemeItemLayout.width, + height: colorSchemeItemLayout.height, + borderRadius: SPOTLIGHT_BORDER_RADIUS, + }); + } + }, [currentStepId, colorSchemeItemLayout, setSpotlightArea]); + useEffect(() => { if (currentStepId === 'settingsTts' && ttsItemLayout) { setSpotlightArea({ @@ -404,6 +448,10 @@ const AppSettingsScreen: React.FC = () => { isFirst={index === 0} isLast={index === personalizeItems.length - 1} onPress={item.onPress} + showNewFeatureDot={ + showPortraitPromoHint && + item.id === SETTING_ITEM_ID_MAP.personalize_color_scheme + } /> ); // ウォークスルーのスポットライト対象はレイアウト計測用のViewで包む @@ -418,6 +466,16 @@ const AppSettingsScreen: React.FC = () => { {row} ); + case 'personalize_color_scheme': + return ( + + {row} + + ); case 'personalize_tts': return ( diff --git a/src/screens/BatterySettings.test.tsx b/src/screens/BatterySettings.test.tsx index 953169a190..9476b6af61 100644 --- a/src/screens/BatterySettings.test.tsx +++ b/src/screens/BatterySettings.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, render } from '@testing-library/react-native'; import { createStore, Provider } from 'jotai'; +import { Platform } from 'react-native'; import { STORAGE_KEYS } from '~/constants'; import { storage } from '~/lib/storage'; import { powerSavingLocationEnabledAtom } from '~/store/atoms/battery'; @@ -37,10 +38,36 @@ const renderWithStore = (powerSavingLocationEnabled = false) => { return { ...screen, store }; }; +// jest-expo の既定 Platform.OS は 'ios'。プラットフォーム別の案内を検証する際は +// 明示的に切り替え、afterEach で必ず元へ戻す。 +const originalPlatformOS = Platform.OS; +const setPlatformOS = (os: typeof Platform.OS) => { + Object.defineProperty(Platform, 'OS', { value: os, configurable: true }); +}; + describe('BatterySettingsScreen', () => { afterEach(() => { jest.clearAllMocks(); resetDialogPresentationForTests(); + setPlatformOS(originalPlatformOS); + }); + + it('[iOS] 測位自動休止を含むiOS向けの説明文を表示する', () => { + setPlatformOS('ios'); + + const { getByText, queryByText } = renderWithStore(); + + expect(getByText('powerSavingLocationDescriptionIOS')).toBeTruthy(); + expect(queryByText('powerSavingLocationDescriptionAndroid')).toBeNull(); + }); + + it('[Android] 測位自動休止に触れないAndroid向けの説明文を表示する', () => { + setPlatformOS('android'); + + const { getByText, queryByText } = renderWithStore(); + + expect(getByText('powerSavingLocationDescriptionAndroid')).toBeTruthy(); + expect(queryByText('powerSavingLocationDescriptionIOS')).toBeNull(); }); it('省電力測位モードをONにするとatomとストレージへ保存される', () => { diff --git a/src/screens/BatterySettings.tsx b/src/screens/BatterySettings.tsx index 84e48040fb..5162722e49 100644 --- a/src/screens/BatterySettings.tsx +++ b/src/screens/BatterySettings.tsx @@ -2,6 +2,7 @@ import { useNavigation } from '@react-navigation/native'; import { useAtom, useAtomValue } from 'jotai'; import React, { useCallback, useRef, useState } from 'react'; import { + Platform, Pressable, Animated as RNAnimated, StyleSheet, @@ -133,7 +134,13 @@ const BatterySettingsScreen: React.FC = () => { - {translate('powerSavingLocationDescription')} + {translate( + // 停車中の測位自動休止(pausesUpdatesAutomatically)はiOS専用の挙動で、 + // Android・webでは効かないため案内文からも外す + Platform.OS === 'ios' + ? 'powerSavingLocationDescriptionIOS' + : 'powerSavingLocationDescriptionAndroid' + )} - + {/* 回転しているビューの外に置いて正立させる(案A) */} + + + {selectBoundModal} ); }; diff --git a/src/screens/Privacy.tsx b/src/screens/Privacy.tsx index 8626f88c7e..4b62e7237c 100644 --- a/src/screens/Privacy.tsx +++ b/src/screens/Privacy.tsx @@ -15,29 +15,41 @@ import { isJapanese, translate } from '../translation'; import { showDialog } from '../utils/dialogPresentation'; import { RFValue } from '../utils/rfValue'; +// NOTE: iOS の lineHeight は fontSize の 1.1719 倍(= Roboto 本来の行送り)を超えては +// いけない。RN は Text の高さ計測に指定フォント(Roboto)の行送りを使う一方、描画は +// lineHeight と実際に使われるフォント(和文は Roboto にグリフが無くフォールバックされ +// 1.0em)に従うため、これを超えると確定した枠に収まらない行が丸ごと捨てられ、末尾が +// 欠ける。RFValue は値ごとに Math.round するため RFValue(16)/RFValue(14) のような比は +// 画面高で変動し(画面高 844 では 20/17 = 1.18em で上限超過)、安全域を保証できない。 +// fontSize から直接算出して比率を固定する。 +const IOS_LINE_HEIGHT_RATIO = 1.15; +const DESCRIPTION_FONT_SIZE = RFValue(14); +const HEADING_FONT_SIZE = RFValue(21); +const iosLineHeight = (fontSize: number) => + Platform.select({ ios: Math.floor(fontSize * IOS_LINE_HEIGHT_RATIO) }); + const styles = StyleSheet.create({ root: { flex: 1, justifyContent: 'center', alignItems: 'center', + paddingHorizontal: 24, }, text: { - fontSize: RFValue(14), + fontSize: DESCRIPTION_FONT_SIZE, marginBottom: 12, - paddingHorizontal: 24, - lineHeight: Platform.select({ - ios: RFValue(18), - }), + // NOTE: 余白は root 側へ持たせ、Text の幅は親いっぱいに固定する。Text 自身に + // paddingHorizontal を持たせて親の alignItems: 'center' で内在幅に任せると、 + // 折り返し幅が計測時と描画時でずれる余地が残る。 + alignSelf: 'stretch', + lineHeight: iosLineHeight(DESCRIPTION_FONT_SIZE), }, headingText: { color: '#03a9f4', - fontSize: RFValue(21), + fontSize: HEADING_FONT_SIZE, fontWeight: 'bold', - width: '100%', textAlign: 'center', - lineHeight: Platform.select({ - ios: RFValue(24), - }), + lineHeight: iosLineHeight(HEADING_FONT_SIZE), }, buttons: { flexDirection: 'row', diff --git a/src/screens/SelectLineScreen.render.test.tsx b/src/screens/SelectLineScreen.render.test.tsx index 9ca9ebd113..48886b87dd 100644 --- a/src/screens/SelectLineScreen.render.test.tsx +++ b/src/screens/SelectLineScreen.render.test.tsx @@ -95,6 +95,13 @@ jest.mock('~/components/CommonCard', () => ({ }, })); +jest.mock('~/components/PortraitModePromoBanner', () => ({ + PortraitModePromoBanner: () => { + const { View } = require('react-native'); + return ; + }, +})); + jest.mock('~/components/NowHeader', () => ({ NowHeader: ({ station }: { station: Station | null }) => { const { View, Text } = require('react-native'); diff --git a/src/screens/SelectLineScreen.tsx b/src/screens/SelectLineScreen.tsx index 519f197938..67009fc211 100644 --- a/src/screens/SelectLineScreen.tsx +++ b/src/screens/SelectLineScreen.tsx @@ -20,6 +20,7 @@ import type { Line, LineNested } from '~/@types/graphql'; import { CommonCard } from '~/components/CommonCard'; import { EmptyLineSeparator } from '~/components/EmptyLineSeparator'; import { NowHeader } from '~/components/NowHeader'; +import { PortraitModePromoBanner } from '~/components/PortraitModePromoBanner'; import { SelectBoundModal } from '~/components/SelectBoundModal'; import WalkthroughOverlay from '~/components/WalkthroughOverlay'; import { useDeviceOrientation } from '~/hooks/useDeviceOrientation'; @@ -60,6 +61,9 @@ const styles = StyleSheet.create({ marginTop: 32, marginBottom: 16, }, + portraitPromoBanner: { + marginBottom: 24, + }, }); // RN 0.81 + New Architecture で tintColor がマウント時に無視されるバグの回避用遅延(ms) @@ -347,6 +351,9 @@ const SelectLineScreen = () => { ) : ( <> + {/* 案B: ポートレートモードの追加を知らせるバナー。 + 条件を満たさないときは自身で null を返す */} + { const navigation = useNavigation(); - const SETTING_ITEMS: SettingItem[] = useMemo(() => { - const themes = getSettingsThemes(); - return themes.map((theme) => ({ - id: theme.value, - title: theme.label, - hidden: !isDevApp && theme.devOnly, - })); - }, []); - - const visibleItems = useMemo( - () => SETTING_ITEMS.filter((item) => !item.hidden), - [SETTING_ITEMS] + // getSettingsThemes() が未公開テーマを既に落としているため、ここでの再除外は不要 + const visibleItems: SettingItem[] = useMemo( + () => + getSettingsThemes().map((theme) => ({ + id: theme.value, + title: theme.label, + })), + [] ); const handleApplyTheme = useCallback( diff --git a/src/store/atoms/colorScheme.test.ts b/src/store/atoms/colorScheme.test.ts index 274faa44ff..cbc8901f7a 100644 --- a/src/store/atoms/colorScheme.test.ts +++ b/src/store/atoms/colorScheme.test.ts @@ -7,7 +7,7 @@ import { appColorsAtom, colorSchemePreferenceAtom, isDarkColorSchemeAtom, - overlayAppColorsAtom, + resolvedAppColorsAtom, resolvedColorSchemeAtom, systemColorSchemeAtom, } from './colorScheme'; @@ -60,17 +60,17 @@ describe('colorScheme atoms', () => { // アクションシートなどOS側のレイヤーに描かれるUIは、電光掲示板風テーマの // 配色を持ちようがないため設定値をそのまま反映する - it('overlayAppColorsAtomは電光掲示板風テーマでも配色設定に追従する', () => { + it('resolvedAppColorsAtomは電光掲示板風テーマでも配色設定に追従する', () => { const store = createStore(); store.set(themePreferenceAtom, THEME_PREFERENCE.LED); store.set(colorSchemePreferenceAtom, COLOR_SCHEME_PREFERENCE.DARK); - expect(store.get(overlayAppColorsAtom)).toBe(DARK_APP_COLORS); + expect(store.get(resolvedAppColorsAtom)).toBe(DARK_APP_COLORS); // 画面本体側は従来どおりライトのまま expect(store.get(appColorsAtom)).toBe(LIGHT_APP_COLORS); store.set(colorSchemePreferenceAtom, COLOR_SCHEME_PREFERENCE.LIGHT); - expect(store.get(overlayAppColorsAtom)).toBe(LIGHT_APP_COLORS); + expect(store.get(resolvedAppColorsAtom)).toBe(LIGHT_APP_COLORS); }); it('電光掲示板風テーマ選択中は端末がダークでもライトのパレットを返す', () => { diff --git a/src/store/atoms/colorScheme.ts b/src/store/atoms/colorScheme.ts index 5c8e2f92ea..6296f57162 100644 --- a/src/store/atoms/colorScheme.ts +++ b/src/store/atoms/colorScheme.ts @@ -67,13 +67,17 @@ export const appColorsAtom = atom((get) => { }); /** - * 電光掲示板風テーマの有無を無視した配色。 + * 電光掲示板風テーマの上書きを受けない配色。 * - * アクションシートのように OS 側のレイヤーへ描かれ、電光掲示板風テーマの配色を - * 持ちようがない UI で使う。ここだけ配色設定に追従しないと、他がダークなのに - * シートだけ明るいという不具合に見えるため、テーマではなく設定値をそのまま反映する。 + * 電光掲示板風テーマの配色を持ちようがない UI で使う。次の 2 種類がある。 + * + * - アクションシートのように OS 側のレイヤーへ描かれるもの + * - ポートレートモードの走行画面のように、路線テーマに依存しないレイアウト + * + * ここまで `appColorsAtom` に合わせてしまうと、他がダークなのにその部分だけ + * 明るいという不具合に見えるため、テーマではなく設定値をそのまま反映する。 */ -export const overlayAppColorsAtom = atom( +export const resolvedAppColorsAtom = atom( (get) => APP_COLORS[get(resolvedColorSchemeAtom)] ); diff --git a/src/store/atoms/display.ts b/src/store/atoms/display.ts new file mode 100644 index 0000000000..c40a2ae03f --- /dev/null +++ b/src/store/atoms/display.ts @@ -0,0 +1,24 @@ +import { atom } from 'jotai'; +import { STORAGE_KEYS } from '~/constants/storage'; +import { storage } from '~/lib/storage'; + +// 画面表示に関する設定。フィールド単位のプリミティブatomとして公開し、 +// 読み取りは必ずこちらを購読する(docs/state-management.md 参照)。 +export const portraitModeEnabledAtom = atom(false); + +// 外観画面を開いたか。開いた時点で設定リストとタブの印を消す必要があり、 +// 印を出す画面(AppSettings / FooterTabBar)は外観画面から戻ってきても +// 再マウントされないため、MMKVの読み取りではなくatomで購読させる。 +// powerSavingLocationEnabledAtom と同じく、初期値はMMKVの同期APIでここで確定する +// (印の有無は初回レンダーで確定していないと、一瞬だけ点いて消える)。 +export const portraitPromoAppearanceSeenAtom = atom( + storage.getString(STORAGE_KEYS.PORTRAIT_PROMO_APPEARANCE_SEEN) === 'true' +); + +// 訴求を打ち切ったか。ポートレートモードを一度オンにすると立ち、以降は +// オフに戻されても復活させない。オン→オフを同一セッション中にされても +// バナーやプロンプトが戻ってこないよう、マウント時の値ではなくatomで購読する。 +// 初期値の確定方法は上と同じ。 +export const portraitPromoFinishedAtom = atom( + storage.getString(STORAGE_KEYS.PORTRAIT_PROMO_FINISHED) === 'true' +); diff --git a/src/store/atoms/experimental.ts b/src/store/atoms/experimental.ts deleted file mode 100644 index 6e5f12659b..0000000000 --- a/src/store/atoms/experimental.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { atom } from 'jotai'; - -// 試験的機能のオンオフ。フィールド単位のプリミティブatomとして公開し、 -// 読み取りは必ずこちらを購読する(docs/state-management.md 参照)。 -export const portraitModeEnabledAtom = atom(false); diff --git a/src/utils/actionSheetColors.ts b/src/utils/actionSheetColors.ts index 4ba5e59216..ef7a06a2b8 100644 --- a/src/utils/actionSheetColors.ts +++ b/src/utils/actionSheetColors.ts @@ -15,7 +15,7 @@ import type { AppColors } from '~/constants/colorScheme'; * * アクションシートは OS 側のレイヤーに描かれ電光掲示板風テーマの配色を持ち * ようがないため、ここだけは電光掲示板風テーマでも配色設定に追従させる - * (`overlayAppColorsAtom` を渡す)。追従しないと他がダークなのにシートだけ + * (`resolvedAppColorsAtom` を渡す)。追従しないと他がダークなのにシートだけ * 明るいという不具合に見えてしまう。 */ export const getActionSheetColorOptions = ( diff --git a/src/utils/nativeTtsVoice.test.ts b/src/utils/nativeTtsVoice.test.ts index 1aad46843a..9b0f629b6e 100644 --- a/src/utils/nativeTtsVoice.test.ts +++ b/src/utils/nativeTtsVoice.test.ts @@ -1,5 +1,9 @@ import { type Voice, VoiceQuality } from 'expo-speech'; -import { scoreVoiceQuality, selectBestVoiceIdentifier } from './nativeTtsVoice'; +import { + type NativeVoice, + scoreVoiceQuality, + selectBestVoiceIdentifier, +} from './nativeTtsVoice'; const voice = ( identifier: string, @@ -8,6 +12,30 @@ const voice = ( name = identifier ): Voice => ({ identifier, name, quality, language }); +// patch 済み expo-speech の Android が返す拡張メタデータ付きの音声。 +// 実機の Google TTS ローカル音声は quality が軒並み QUALITY_NORMAL(300) に +// 並ぶため、識別子順以外のタイブレークが効くかどうかをここで検証する。 +const androidVoice = ( + identifier: string, + language: string, + extras: Partial< + Pick< + NativeVoice, + 'qualityScore' | 'isDefault' | 'networkRequired' | 'notInstalled' + > + > = {} +): NativeVoice => ({ + identifier, + name: identifier, + quality: VoiceQuality.Default, + language, + qualityScore: 300, + isDefault: false, + networkRequired: false, + notInstalled: false, + ...extras, +}); + describe('scoreVoiceQuality', () => { it('premium識別子を最高スコアにする', () => { expect( @@ -177,3 +205,86 @@ describe('selectBestVoiceIdentifier', () => { ); }); }); + +describe('selectBestVoiceIdentifier (Android拡張メタデータ)', () => { + const options = { allowDefaultQuality: true }; + + it('品質が同点なら端末既定音声を選ぶ', () => { + // ユーザーが端末設定で選んだ音声を尊重する。識別子順では 'htm' が勝つ並び + const voices = [ + androidVoice('ja-jp-x-htm-local', 'ja-JP'), + androidVoice('ja-jp-x-jad-local', 'ja-JP', { isDefault: true }), + ]; + expect(selectBestVoiceIdentifier(voices, 'ja-JP', options)).toBe( + 'ja-jp-x-jad-local' + ); + }); + + it('生の品質値が高い音声を識別子順より優先する', () => { + const voices = [ + androidVoice('ja-jp-x-htm-local', 'ja-JP', { qualityScore: 300 }), + androidVoice('ja-jp-x-jad-local', 'ja-JP', { qualityScore: 400 }), + ]; + expect(selectBestVoiceIdentifier(voices, 'ja-JP', options)).toBe( + 'ja-jp-x-jad-local' + ); + }); + + it('端末既定音声より地域一致を優先する', () => { + const voices = [ + androidVoice('en-gb-x-gba-local', 'en-GB', { isDefault: true }), + androidVoice('en-us-x-iob-local', 'en-US'), + ]; + expect(selectBestVoiceIdentifier(voices, 'en-US', options)).toBe( + 'en-us-x-iob-local' + ); + }); + + it('未インストールの音声は他に候補があれば選ばない', () => { + // 品質値が高くても音声データが無ければ合成できない + const voices = [ + androidVoice('ja-jp-x-jab-local', 'ja-JP', { + qualityScore: 500, + notInstalled: true, + }), + androidVoice('ja-jp-x-jad-local', 'ja-JP', { qualityScore: 300 }), + ]; + expect(selectBestVoiceIdentifier(voices, 'ja-JP', options)).toBe( + 'ja-jp-x-jad-local' + ); + }); + + it('未インストールの音声しか無ければ最後の手段として選ぶ', () => { + // 音声未指定のまま進めると端末既定言語で合成されてしまうため、 + // 合成できない可能性があっても明示指定する方がマシ + const voices = [ + androidVoice('ja-jp-x-jad-local', 'ja-JP', { notInstalled: true }), + ]; + expect(selectBestVoiceIdentifier(voices, 'ja-JP', options)).toBe( + 'ja-jp-x-jad-local' + ); + }); + + it('識別子にnetworkを含まなくてもnetworkRequiredでローカルを優先する', () => { + const voices = [ + androidVoice('ja-jp-x-jab-cloud', 'ja-JP', { + qualityScore: 500, + networkRequired: true, + }), + androidVoice('ja-jp-x-jad-local', 'ja-JP', { qualityScore: 300 }), + ]; + expect(selectBestVoiceIdentifier(voices, 'ja-JP', options)).toBe( + 'ja-jp-x-jad-local' + ); + }); + + it('拡張メタデータが無いiOSの音声では従来の判定を維持する', () => { + const voices = [ + voice('com.apple.voice.compact.ja-JP.Kyoko', 'ja-JP'), + voice('com.apple.voice.premium.ja-JP.Kyoko', 'ja-JP'), + ]; + expect(selectBestVoiceIdentifier(voices, 'ja-JP')).toBe( + 'com.apple.voice.premium.ja-JP.Kyoko' + ); + }); +}); diff --git a/src/utils/nativeTtsVoice.ts b/src/utils/nativeTtsVoice.ts index 42df3ecd4b..4ed9614c5b 100644 --- a/src/utils/nativeTtsVoice.ts +++ b/src/utils/nativeTtsVoice.ts @@ -17,12 +17,34 @@ import { type Voice, VoiceQuality } from 'expo-speech'; // オプションを用意し、地域違い(en-GB 等)やネットワーク必須音声も // フォールバック候補に含める。 // +// Android のローカル音声は Google TTS の日本語のように quality が軒並み +// QUALITY_NORMAL へ並ぶため、expo-speech が返す 2 値の quality だけでは優劣を +// 判定できず、識別子のアルファベット順という無根拠なタイブレークで音声が決まって +// しまう('ja-jp-x-htm-local' が常に勝ち、ユーザーが端末設定で選んだ音声も無視 +// される)。そのため expo-speech の Android 実装へ patch を当てて生の +// Voice メタデータ(qualityScore / isDefault / networkRequired / notInstalled) +// を受け取り、それらを優先順の判断材料にする。iOS ではこれらのフィールドは +// 返らないため、従来どおり識別子と quality で判定する。 +// // 注意: expo-speech の iOS 実装は AVSpeechSynthesisVoiceQuality.premium を // "Default" として報告する(native 側が enhanced 以外を一律 Default に落とす)ため、 // quality フィールドだけでは Premium 音声を検出できない。iOS の音声識別子は // `com.apple.voice.premium.ja-JP.Kyoko` / `com.apple.ttsbundle.Kyoko-premium` の // ように品質を含む命名になっているので、識別子でも判定して補完する。 +// expo-speech の Voice 型は Android patch で追加したフィールドを含まないため、 +// アプリ側で拡張して扱う。iOS ではいずれも返らないので optional にしている。 +export type NativeVoice = Voice & { + // android.speech.tts.Voice.getQuality() の生値 (VERY_LOW=100 〜 VERY_HIGH=500) + qualityScore?: number; + // 端末設定で選ばれているエンジン既定音声か + isDefault?: boolean; + // 合成にネットワーク接続が必要か + networkRequired?: boolean; + // 音声データが未ダウンロードで、指定しても合成できない状態か + notInstalled?: boolean; +}; + const normalizeLanguageTag = (tag: string): string => tag.toLowerCase().replace(/_/g, '-'); @@ -30,7 +52,7 @@ const primarySubtag = (tag: string): string => normalizeLanguageTag(tag).split('-')[0] ?? ''; // 品質スコア。premium > enhanced > その他。 -export const scoreVoiceQuality = (voice: Voice): number => { +export const scoreVoiceQuality = (voice: NativeVoice): number => { const id = (voice.identifier ?? '').toLowerCase(); if (id.includes('premium')) { return 3; @@ -51,14 +73,32 @@ export interface SelectBestVoiceOptions { // Android の '-network' 音声はネットワーク接続必須。乗車中はトンネル等で接続が // 切れやすく読み上げが失敗しうるため、ローカル音声を優先し最後の手段としてのみ使う。 -const isNetworkVoice = (voice: Voice): boolean => +// patch 済みの Android は Voice.isNetworkConnectionRequired() を返すのでそれを使い、 +// 返らない環境では従来どおり識別子の 'network' で判定する。 +const isNetworkVoice = (voice: NativeVoice): boolean => + voice.networkRequired ?? (voice.identifier ?? '').toLowerCase().includes('network'); +// 音声データ未ダウンロードの音声は指定しても合成できない。候補が他に無いときの +// 保険として残したいので、除外はせず優先順の最劣後へ回す。 +const isNotInstalledVoice = (voice: NativeVoice): boolean => + voice.notInstalled === true; + +// Android の生の品質値。iOS では返らないため、その場合は同点として扱い +// 後続の識別子ベースの品質判定へ委ねる。 +const nativeQualityScore = (voice: NativeVoice): number => + voice.qualityScore ?? 0; + +// 端末設定で選ばれているエンジン既定音声か。ユーザーの明示的な選択なので、 +// 同じ地域の候補が並んだときは機械的な優劣より優先する。 +const isDefaultVoice = (voice: NativeVoice): boolean => + voice.isDefault === true; + // 指定言語で最適な音声識別子を返す。地域まで一致する音声を優先しつつ、 // 無ければ同一言語の別地域(en-US が無い端末の en-GB 等)も候補にする。 // 条件を満たす音声が無い場合は undefined を返してシステム既定に任せる。 export const selectBestVoiceIdentifier = ( - voices: Voice[], + voices: NativeVoice[], language: string, options?: SelectBestVoiceOptions ): string | undefined => { @@ -71,12 +111,17 @@ export const selectBestVoiceIdentifier = ( const best = voices .filter((v) => primarySubtag(v.language ?? '') === targetPrimary) .filter((v) => scoreVoiceQuality(v) >= minScore) - // ローカル > 地域一致 > 品質 の優先順。同点は識別子順で決定的に選ぶ - // (実行ごとに音声が変わらないように) + // インストール済み > ローカル > 地域一致 > 端末既定 > 品質 の優先順。 + // 最後まで差がつかない場合のみ識別子順で決定的に選ぶ(実行ごとに音声が + // 変わらないように)。識別子順は品質と無関係なので、その手前で判断材料を + // 出し切るのが狙い。 .sort( (a, b) => + Number(isNotInstalledVoice(a)) - Number(isNotInstalledVoice(b)) || Number(isNetworkVoice(a)) - Number(isNetworkVoice(b)) || Number(isExactRegion(b)) - Number(isExactRegion(a)) || + Number(isDefaultVoice(b)) - Number(isDefaultVoice(a)) || + nativeQualityScore(b) - nativeQualityScore(a) || scoreVoiceQuality(b) - scoreVoiceQuality(a) || (a.identifier ?? '').localeCompare(b.identifier ?? '') ) diff --git a/src/utils/numberingGlyphLift.test.ts b/src/utils/numberingGlyphLift.test.ts new file mode 100644 index 0000000000..469142dd64 --- /dev/null +++ b/src/utils/numberingGlyphLift.test.ts @@ -0,0 +1,45 @@ +import { Platform } from 'react-native'; +import { numberingGlyphLift } from './numberingGlyphLift'; + +describe('numberingGlyphLift', () => { + const originalOS = Platform.OS; + + afterEach(() => { + Object.defineProperty(Platform, 'OS', { value: originalOS }); + jest.clearAllMocks(); + }); + + const setOS = (os: typeof Platform.OS) => + Object.defineProperty(Platform, 'OS', { value: os }); + + it('iOSでは補正しない', () => { + setOS('ios'); + expect(numberingGlyphLift(24)).toEqual([]); + }); + + // FrutigerNeueLTPro-Bold のメトリクスから導かれる比率は約 0.084em + it('Androidでは行の高さに比例して上方向へ補正する', () => { + setOS('android'); + expect(numberingGlyphLift(8)).toEqual([{ translateY: -1 }]); + expect(numberingGlyphLift(24)).toEqual([{ translateY: -2 }]); + expect(numberingGlyphLift(36)).toEqual([{ translateY: -3 }]); + }); + + it('補正量は行の高さに対して単調増加する', () => { + setOS('android'); + const lifts = [8, 12, 20, 24, 32, 48].map( + (lh) => -numberingGlyphLift(lh)[0].translateY + ); + for (let i = 1; i < lifts.length; i++) { + expect(lifts[i]).toBeGreaterThanOrEqual(lifts[i - 1]); + } + }); + + it('補正量は常に上方向(負)になる', () => { + setOS('android'); + for (const lineHeight of [8, 10, 12, 17, 20, 24, 30, 32, 45, 48]) { + const [{ translateY }] = numberingGlyphLift(lineHeight); + expect(translateY).toBeLessThan(0); + } + }); +}); diff --git a/src/utils/numberingGlyphLift.ts b/src/utils/numberingGlyphLift.ts new file mode 100644 index 0000000000..ae97c1ebf6 --- /dev/null +++ b/src/utils/numberingGlyphLift.ts @@ -0,0 +1,45 @@ +import { Platform } from 'react-native'; + +/** + * FrutigerNeueLTPro-Bold の縦メトリクス(unitsPerEm=1000 に対する比率)。 + * android/app/src/main/assets/fonts/FrutigerNeueLTPro-Bold.ttf の hhea / OS/2 の値。 + */ +const FONT_ASCENT = 1.13; +const FONT_DESCENT = 0.264; +const FONT_CAP_HEIGHT = 0.698; + +/** + * Android でナンバリングのグリフが行ボックス内で下寄りになる分の比率。 + * + * RN Android の CustomLineHeightSpan は lineHeight とフォントの ascent+descent の差 + * (leading) を上下へ等分するため、行ボックスの中心はフォントの ascent/descent ボックスの + * 中心に一致する。ナンバリングの記号と番号は大文字と数字だけでディセンダを持たないので、 + * ベースラインより下の descent がそのまま余白として残り、グリフが下寄りに見える。 + * ズレ量は (ascent - descent) - capHeight で、その半分を引き上げると上下が揃う。 + * 式から lineHeight が消えることからも分かるとおり、補正量は文字サイズに比例する。 + * + * 端末やOSではなくフォントに依存する値である点に注意: + * フォントは APK 同梱で `Typography` も allowFontScaling={false} のため、端末や + * フォントサイズ設定では変わらない。一方 myriadpro-bold は -0.087em、FuturaLTPro-Bold は + * -0.052em と符号が逆になるので、別フォントのアイコンにそのまま流用してはいけない。 + * React Native 0.86.2 の CustomLineHeightSpan を前提にしているので、RN のメジャー + * アップグレード時は実機で見た目を確認すること。 + */ +const GLYPH_LIFT_RATIO = (FONT_ASCENT - FONT_DESCENT - FONT_CAP_HEIGHT) / 2; + +/** + * Android のグリフ下寄り分を打ち消す transform を返す(iOS は行ボックス内で中央に + * 描かれるため補正しない)。 + * + * marginTop の負値では兄弟要素ごと動いてアイコン全体が縮むため、レイアウトに影響しない + * transform で文字だけを持ち上げる。 + * + * 記号と番号で異なる値を渡すと両者の間隔まで変わってしまうので、1つのアイコンでは + * 必ず同じ戻り値を使い回すこと。 + * + * @param baseLineHeight 基準にする行の高さ(本リポジトリでは lineHeight === fontSize) + */ +export const numberingGlyphLift = (baseLineHeight: number) => + Platform.OS === 'android' + ? [{ translateY: -Math.round(baseLineHeight * GLYPH_LIFT_RATIO) }] + : []; diff --git a/src/utils/portraitPromo.test.ts b/src/utils/portraitPromo.test.ts new file mode 100644 index 0000000000..29ffdadc22 --- /dev/null +++ b/src/utils/portraitPromo.test.ts @@ -0,0 +1,104 @@ +import { STORAGE_KEYS } from '~/constants/storage'; +import { storage } from '~/lib/storage'; +import { + canShowPortraitAppearanceHint, + canShowPortraitBanner, + canShowPortraitPrompt, + finishPortraitPromo, + isPortraitPromoFinished, + markPortraitAppearanceSeen, + PORTRAIT_BANNER_MAX_COUNT, + PORTRAIT_PROMPT_COOLDOWN_MS, + PORTRAIT_PROMPT_MAX_COUNT, + recordPortraitBannerShown, + recordPortraitPromptDismissed, +} from './portraitPromo'; + +describe('portraitPromo', () => { + beforeEach(() => { + storage.clearAll(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('finishPortraitPromo', () => { + it('打ち切りフラグを立てるとすべての訴求が止まる', () => { + expect(isPortraitPromoFinished()).toBe(false); + expect(canShowPortraitPrompt()).toBe(true); + expect(canShowPortraitBanner()).toBe(true); + expect(canShowPortraitAppearanceHint()).toBe(true); + + finishPortraitPromo(); + + expect(isPortraitPromoFinished()).toBe(true); + expect(canShowPortraitPrompt()).toBe(false); + expect(canShowPortraitBanner()).toBe(false); + expect(canShowPortraitAppearanceHint()).toBe(false); + }); + }); + + describe('canShowPortraitPrompt', () => { + it('未提示なら出せる', () => { + expect(canShowPortraitPrompt()).toBe(true); + }); + + it('「今はしない」の直後はクールダウン中なので出さない', () => { + const now = 1_700_000_000_000; + recordPortraitPromptDismissed(now); + + expect(canShowPortraitPrompt(now + 1000)).toBe(false); + expect(canShowPortraitPrompt(now + PORTRAIT_PROMPT_COOLDOWN_MS - 1)).toBe( + false + ); + }); + + it('クールダウンを過ぎたら再度出せる', () => { + const now = 1_700_000_000_000; + recordPortraitPromptDismissed(now); + + expect(canShowPortraitPrompt(now + PORTRAIT_PROMPT_COOLDOWN_MS)).toBe( + true + ); + }); + + it('上限回数まで出したら、クールダウンを過ぎても出さない', () => { + const now = 1_700_000_000_000; + for (let i = 0; i < PORTRAIT_PROMPT_MAX_COUNT; i++) { + recordPortraitPromptDismissed(now + i); + } + + expect( + canShowPortraitPrompt(now + PORTRAIT_PROMPT_COOLDOWN_MS * 10) + ).toBe(false); + }); + }); + + describe('canShowPortraitBanner', () => { + it('上限回数まで表示したら出さない', () => { + for (let i = 0; i < PORTRAIT_BANNER_MAX_COUNT; i++) { + expect(canShowPortraitBanner()).toBe(true); + recordPortraitBannerShown(); + } + + expect(canShowPortraitBanner()).toBe(false); + }); + }); + + describe('canShowPortraitAppearanceHint', () => { + it('外観画面を一度開いたら印を出さない', () => { + expect(canShowPortraitAppearanceHint()).toBe(true); + + markPortraitAppearanceSeen(); + + expect(canShowPortraitAppearanceHint()).toBe(false); + }); + }); + + it('壊れたカウンタ値は0として扱う', () => { + storage.set(STORAGE_KEYS.PORTRAIT_PROMO_BANNER_COUNT, 'not-a-number'); + + expect(canShowPortraitBanner()).toBe(true); + }); +}); diff --git a/src/utils/portraitPromo.ts b/src/utils/portraitPromo.ts new file mode 100644 index 0000000000..6e6373a424 --- /dev/null +++ b/src/utils/portraitPromo.ts @@ -0,0 +1,118 @@ +import { STORAGE_KEYS } from '~/constants/storage'; +import { storage } from '~/lib/storage'; + +/** 走行画面のプロンプト(案A)を出す通算上限 */ +export const PORTRAIT_PROMPT_MAX_COUNT = 2; + +/** 「今はしない」で閉じたあと、次に出すまで空ける時間 */ +export const PORTRAIT_PROMPT_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; + +/** ホームのバナー(案B)を出す通算上限 */ +export const PORTRAIT_BANNER_MAX_COUNT = 3; + +const readFlag = (key: string): boolean => { + try { + return storage.getString(key) === 'true'; + } catch (error) { + console.error('Failed to read portrait promo flag', error); + return false; + } +}; + +const readCounter = (key: string): number => { + try { + const raw = storage.getString(key); + if (!raw) { + return 0; + } + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; + } catch (error) { + console.error('Failed to read portrait promo counter', error); + return 0; + } +}; + +// 訴求の記録は落としても実害が無い(せいぜい1回多く出る)ため、 +// 保存に失敗しても呼び出し側にはエラーを伝播させない。 +const write = (key: string, value: string): void => { + try { + storage.set(key, value); + } catch (error) { + console.error('Failed to save portrait promo state', error); + } +}; + +/** 訴求を打ち切ったか。ポートレートモードを一度オンにすると立つ */ +export const isPortraitPromoFinished = (): boolean => + readFlag(STORAGE_KEYS.PORTRAIT_PROMO_FINISHED); + +/** + * 訴求を恒久的に打ち切る。オンにした時点で案A・案B・案Cのすべてを止める。 + * あとから設定でオフに戻しても復活させない。 + */ +export const finishPortraitPromo = (): void => { + write(STORAGE_KEYS.PORTRAIT_PROMO_FINISHED, 'true'); +}; + +/** 走行画面のプロンプト(案A)を出してよいか */ +export const canShowPortraitPrompt = (now: number = Date.now()): boolean => { + if (isPortraitPromoFinished()) { + return false; + } + if ( + readCounter(STORAGE_KEYS.PORTRAIT_PROMO_PROMPT_COUNT) >= + PORTRAIT_PROMPT_MAX_COUNT + ) { + return false; + } + const lastShownAt = readCounter( + STORAGE_KEYS.PORTRAIT_PROMO_PROMPT_LAST_SHOWN_AT + ); + if (!lastShownAt) { + return true; + } + return now - lastShownAt >= PORTRAIT_PROMPT_COOLDOWN_MS; +}; + +/** 「今はしない」で閉じられたことを記録し、次に出すまでの間隔を空ける */ +export const recordPortraitPromptDismissed = ( + now: number = Date.now() +): void => { + const count = readCounter(STORAGE_KEYS.PORTRAIT_PROMO_PROMPT_COUNT); + write(STORAGE_KEYS.PORTRAIT_PROMO_PROMPT_COUNT, String(count + 1)); + write(STORAGE_KEYS.PORTRAIT_PROMO_PROMPT_LAST_SHOWN_AT, String(now)); +}; + +/** ホームのバナー(案B)を出してよいか */ +export const canShowPortraitBanner = (): boolean => { + if (isPortraitPromoFinished()) { + return false; + } + return ( + readCounter(STORAGE_KEYS.PORTRAIT_PROMO_BANNER_COUNT) < + PORTRAIT_BANNER_MAX_COUNT + ); +}; + +/** バナーを1回表示したことを記録する */ +export const recordPortraitBannerShown = (): void => { + const count = readCounter(STORAGE_KEYS.PORTRAIT_PROMO_BANNER_COUNT); + write(STORAGE_KEYS.PORTRAIT_PROMO_BANNER_COUNT, String(count + 1)); +}; + +/** + * 設定リストの印とスポットライト(案C)を出してよいか。 + * 外観画面を一度開いた時点で両方とも消える。 + */ +export const canShowPortraitAppearanceHint = (): boolean => { + if (isPortraitPromoFinished()) { + return false; + } + return !readFlag(STORAGE_KEYS.PORTRAIT_PROMO_APPEARANCE_SEEN); +}; + +/** 外観画面を開いたことを記録する */ +export const markPortraitAppearanceSeen = (): void => { + write(STORAGE_KEYS.PORTRAIT_PROMO_APPEARANCE_SEEN, 'true'); +}; diff --git a/src/utils/relativeEtaStops.test.ts b/src/utils/relativeEtaStops.test.ts new file mode 100644 index 0000000000..5d6687c420 --- /dev/null +++ b/src/utils/relativeEtaStops.test.ts @@ -0,0 +1,104 @@ +import { toRelativeEtaStops } from './relativeEtaStops'; + +const stop = ( + stationId: number | null, + cumulativeMinutes: number | null, + departureCumulativeMinutes: number | null = cumulativeMinutes +) => ({ stationId, cumulativeMinutes, departureCumulativeMinutes }); + +describe('toRelativeEtaStops', () => { + it('現在駅の出発時刻を基準(0分)とした相対値に変換する', () => { + const stops = [stop(1, 0), stop(2, 5, 6), stop(3, 12), stop(4, 20)]; + + expect(toRelativeEtaStops(stops, 2)).toEqual([ + expect.objectContaining({ stationId: 3, cumulativeMinutes: 6 }), + expect.objectContaining({ stationId: 4, cumulativeMinutes: 14 }), + ]); + }); + + it('現在駅自身とそれより前の駅を除外する', () => { + const stops = [stop(1, 0), stop(2, 5), stop(3, 12)]; + + expect(toRelativeEtaStops(stops, 2).map((s) => s.stationId)).toEqual([3]); + }); + + it('現在駅の出発時刻が欠けて基準が0に落ちても、現在駅自身にはETAを付けない', () => { + // 基準が 0 にフォールバックすると現在駅の相対値は生の値のまま正になり、 + // 相対値のフィルタだけでは残ってしまう。station id で明示的に除いて + // 「停車中の駅には ETA を出さない」不変条件を計算結果に依存せず保つ。 + const stops = [stop(1, 0), stop(2, 5, null), stop(3, 12)]; + + expect( + toRelativeEtaStops(stops, 2).map((s) => [ + s.stationId, + s.cumulativeMinutes, + ]) + ).toEqual([[3, 12]]); + }); + + it('区間内に現在駅が見つからないときは基準を0として扱う', () => { + const stops = [stop(1, 0), stop(2, 5), stop(3, 12)]; + + expect( + toRelativeEtaStops(stops, 99).map((s) => [ + s.stationId, + s.cumulativeMinutes, + ]) + ).toEqual([ + [2, 5], + [3, 12], + ]); + }); + + it('現在駅が未確定のときは id 無しの stop を基準に取らない', () => { + // currentStationId が undefined だと stationId 未設定の stop と + // undefined 同士で一致してしまい、無関係な出発時刻が基準に入る。 + const stops = [ + { + stationId: undefined, + cumulativeMinutes: 4, + departureCumulativeMinutes: 30, + }, + stop(2, 5), + stop(3, 12), + ]; + + expect( + toRelativeEtaStops(stops, undefined).map((s) => [ + s.stationId, + s.cumulativeMinutes, + ]) + ).toEqual([ + [2, 5], + [3, 12], + ]); + }); + + it('stationId が無い stop は除外する', () => { + const stops = [stop(null, 5), stop(3, 12)]; + + expect(toRelativeEtaStops(stops, 1).map((s) => s.stationId)).toEqual([3]); + }); + + it('cumulativeMinutes が null の stop は相対値を持たないまま残す', () => { + const stops = [stop(2, 5), stop(3, null, 12)]; + + expect(toRelativeEtaStops(stops, 2)).toEqual([ + expect.objectContaining({ stationId: 3, cumulativeMinutes: null }), + ]); + }); + + it('元の stop の他のフィールドを保つ', () => { + const stops = [ + { ...stop(2, 5), stopsHere: true }, + { ...stop(3, 12), stopsHere: false }, + ]; + + expect(toRelativeEtaStops(stops, 2)[0]).toEqual({ + stationId: 3, + cumulativeMinutes: 7, + departureCumulativeMinutes: 12, + stopsHere: false, + }); + }); +}); diff --git a/src/utils/relativeEtaStops.ts b/src/utils/relativeEtaStops.ts new file mode 100644 index 0000000000..c6935ccd7b --- /dev/null +++ b/src/utils/relativeEtaStops.ts @@ -0,0 +1,48 @@ +/** estimateArrivalTimes の stop のうち、相対時間への変換に必要な部分だけ。 */ +export type EtaStopLike = { + stationId?: number | null; + cumulativeMinutes?: number | null; + departureCumulativeMinutes?: number | null; +}; + +/** + * 絶対累積分を持つ stops を、現在駅の出発時刻を基準(0分)とした相対時間に変換する。 + * + * 表示側で絞り込む前の全 stops を渡す。基準となる現在駅の出発時刻は絞り込みの + * 有無に依らず全 stops から引く必要があるため、絞り込みは呼び出し側でこの関数の + * 後に行う(先に絞ると区間外に出た現在駅を見失って基準が 0 にフォールバックする)。 + * + * 除外する stop は2種類: + * - 現在駅自身。相対値は通常 0 以下になり下の条件で落ちるが、区間内に現在駅の + * エントリが見つからず基準が 0 にフォールバックしたときは生の値が残ってしまう。 + * 「停車中の駅には ETA を出さない」という表示上の不変条件を計算結果に依存せず + * 保証するため、station id で明示的に除く。 + * - 相対値が 0 以下になった stop(= すでに通り過ぎた駅)。 + */ +export const toRelativeEtaStops = ( + stops: readonly T[], + currentStationId: number | null | undefined +): (T & { cumulativeMinutes: number | null })[] => { + // 環状区間(6の字運転)では同じ駅が全stops中に複数回出現する。ただしこれらは + // stationGroupId(同一駅を束ねる論理グループ)こそ共通だが、stationId は出現ごとに + // 別々に採番されている(例: 都営大江戸線 都庁前の外回り/内回りはそれぞれ別の + // stationId を持つ)。そのため stationGroupId で突き合わせると無関係な出現まで + // 拾ってしまうが、stationId なら出現ごとに一意なので誤って混同することがない。 + // 現在駅が分からないときは探しに行かない。id 無しの stop は stationId が + // undefined になりうるので、undefined 同士で一致してしまい、無関係な stop の + // 出発時刻を基準に据えてしまう。 + const baseMinutes = + currentStationId == null + ? 0 + : (stops.find((s) => s.stationId === currentStationId) + ?.departureCumulativeMinutes ?? 0); + + return stops + .filter((s) => s.stationId != null && s.stationId !== currentStationId) + .map((s) => ({ + ...s, + cumulativeMinutes: + s.cumulativeMinutes == null ? null : s.cumulativeMinutes - baseMinutes, + })) + .filter((s) => s.cumulativeMinutes == null || s.cumulativeMinutes > 0); +}; diff --git a/src/utils/theme.test.ts b/src/utils/theme.test.ts new file mode 100644 index 0000000000..682617f4fa --- /dev/null +++ b/src/utils/theme.test.ts @@ -0,0 +1,57 @@ +import { isClip } from 'react-native-app-clip'; +import { APP_THEME } from '~/models/Theme'; + +jest.mock('react-native-app-clip', () => ({ + isClip: jest.fn(() => false), +})); + +jest.mock('~/translation', () => ({ + translate: (key: string) => key, + isJapanese: false, +})); + +const mockedIsClip = jest.mocked(isClip); + +// isDevApp は import 時に評価される定数のため、テストごとにモック値を変えて +// モジュールを読み直す +const loadThemes = (isDevApp: boolean) => { + let themes: ReturnType = []; + jest.isolateModules(() => { + jest.doMock('./isDevApp', () => ({ isDevApp })); + themes = require('./theme').getSettingsThemes(); + }); + return themes; +}; + +describe('getSettingsThemes', () => { + beforeEach(() => { + mockedIsClip.mockReturnValue(false); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('本番ビルドでは devOnly なテーマを一覧に含めない', () => { + const themes = loadThemes(false); + expect(themes.some((t) => t.devOnly)).toBe(false); + expect(themes.some((t) => t.value === APP_THEME.LOW_POWER)).toBe(false); + }); + + it('カナリア版では devOnly なテーマも一覧に含める', () => { + const themes = loadThemes(true); + expect(themes.some((t) => t.value === APP_THEME.LOW_POWER)).toBe(true); + }); + + it('本番ビルドでも devOnly でないテーマは一覧に残る', () => { + const themes = loadThemes(false); + expect(themes.some((t) => t.value === APP_THEME.TOKYO_METRO)).toBe(true); + expect(themes.some((t) => t.value === APP_THEME.LED)).toBe(true); + }); + + it('App Clip では LED テーマを一覧に含めない', () => { + mockedIsClip.mockReturnValue(true); + const themes = loadThemes(false); + expect(themes.some((t) => t.value === APP_THEME.LED)).toBe(false); + }); +}); diff --git a/src/utils/theme.ts b/src/utils/theme.ts index 53e8fcf0ff..9db116403b 100644 --- a/src/utils/theme.ts +++ b/src/utils/theme.ts @@ -5,6 +5,7 @@ import { type ThemePreference, } from '~/models/Theme'; import { translate } from '~/translation'; +import { isDevApp } from './isDevApp'; export interface SettingsTheme { label: string; @@ -79,4 +80,18 @@ export const getSettingsThemes = (): SettingsTheme[] => value: APP_THEME.E231, devOnly: false, }, - ].filter((t) => (isClip() ? t.value !== APP_THEME.LED : t)); // App Clip では LED テーマを非表示 + { + label: translate('lowPowerTheme'), + value: APP_THEME.LOW_POWER, + // コードネームは低消費電力テーマ(#3697)。まずカナリア版だけで様子を見る + devOnly: true, + }, + ].filter((t) => { + // App Clip では LED テーマを非表示 + if (isClip() && t.value === APP_THEME.LED) { + return false; + } + // 未公開テーマはカナリア版でのみ選べるようにする。 + // 呼び出し側ごとに除外すると片方だけ漏れるため、一覧を組み立てるここで一元的に落とす + return isDevApp || !t.devOnly; + }); diff --git a/src/utils/themeInfo.ts b/src/utils/themeInfo.ts index 0503bd3c7b..2c23207812 100644 --- a/src/utils/themeInfo.ts +++ b/src/utils/themeInfo.ts @@ -74,6 +74,11 @@ const APP_THEME_INFO_MAP: Record = { spImage: require('../../assets/images/themes/e231-sp.webp'), tabletImage: require('../../assets/images/themes/e231-tablet.webp'), }, + [APP_THEME.LOW_POWER]: { + descriptionKey: 'themeDescriptionLowPower', + spImage: require('../../assets/images/themes/low-power-sp.webp'), + tabletImage: require('../../assets/images/themes/low-power-tablet.webp'), + }, } as const; export const getThemeInfo = (theme: AppTheme): ThemeInfo => {