Author SHA1 Message Date
harukin-expo-dev-env d622be4432 fix: scope playback operation logs to JST service date 2026-10-06 13:17:13 +09:00
harukin-expo-dev-env a58fe6e7e1 feat: add pure service state resolver with railway fixtures 2026-10-06 13:16:38 +09:00
harukin-expo-dev-env 026c6bfbb2 docs: preserve shared service-plan v1 contract 2026-10-06 13:13:20 +09:00
harukin-expo-dev-env b615dbab4f Merge commit '9f29ef7cee64dd2b6e71b4e47934c0d218b6d79e' into develop 2026-10-06 13:05:51 +09:00
Claude 9f29ef7cee fix: mock位置マスターをbackendの/api/v1/position-mastersから取得する
position-api(D1)側の位置マスターは更新が止まっているため、mockモードの
Pos補完をbackendの位置マスターに切り替える。レスポンス形式の差
({data:[]}包み、pos_numが文字列、pos_type→position_kind、pos_textのnull)
はpositionMasterParserで旧形式に変換する。

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01UFcahnwpdMNuf3ZmoSkJWq
2026-10-06 03:56:01 +00:00
harukin-expo-dev-env 9dcdb7ed52 Merge commit 'bb586fd318dd141fefc10ee1517f432d17275f71' into develop 2026-10-06 12:54:41 +09:00
harukin-expo-dev-envandClaude Opus 5.5 bb586fd318 perf: 在線の取得をサーバー更新周期に合わせ、時刻表と公式位置の不要な再取得を省く
- 在線: fetchedAtとDateヘッダーから次回取得をサーバー更新の約2秒後に合わせる。
  未更新なら2秒後に1回だけ再取得し、mock・録画・GAS fallbackは固定15秒を維持する。
  更新から反映までの遅れが平均約7.5秒から約2.5秒になる(本番APIで実測)。
- 公式位置メタデータ: 15秒ごとの強制再取得をやめ、5分のTTLにする。
  バックグラウンドからの復帰時は強制再取得し、内容が同じなら永続キャッシュを書き直さない。
- 時刻表: ETagで条件付き取得し、未変更ならパース・再描画・保存を省く。
  ETagをキャッシュにも保存し、起動直後の取得から304で済ませる。
- observedFetchにknownEtagとETag/Date/受信時刻を返すobservedFetchJsonWithMetaを追加。
- キャッシュ読込がネットワーク結果を上書きする競合を防ぐ。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 12:54:20 +09:00
harukin-expo-dev-envandClaude Opus 5.5 8f6633fc1b fix: 外部URLを開く失敗とAndroidウィジェットの取得失敗を処理する
- Linking.openURLをopenExternalUrlに置き換え、失敗時は未処理rejectにせずAlertで伝える
- Androidの運行情報・遅延情報ウィジェットで、取得失敗時に「通常運行」と誤表示しないようにする

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-03 00:14:41 +09:00
harukin-expo-dev-env ef50aa96f7 feat: telemetryのログ記録機能を追加し、通信失敗時のエラーハンドリングを改善 2026-10-02 22:53:42 +09:00
harukin-expo-dev-envandClaude Opus 5.5 ab48ae0f6e feat: fontSize/radiusのデザイントークンを追加し、未保存キー読込の未処理rejectを防ぐ
- lib/theme/tokens.tsにfontSize/radiusトークンを追加し、lib/themeから公開する
- 汎用UIのfontSize/borderRadius直書きをESLintで警告する(再現表示・ウィジェット等は対象外)
- storage.loadはキーが無いとNotFoundErrorでrejectするため、catchの無かった
  unyohubData/elesiteData、データソース設定、通知設定の読込にcatchを追加する
  (Sentry JR-SHIKOKU-UNOFFICIAL-APPS-1)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 13:15:24 +09:00
harukin-expo-dev-envandClaude Opus 5.5 586fcfe24d chore: Claude CodeがSentry MCPを読み込めるよう.mcp.jsonにtypeを追加
typeがないとClaude Codeはこのエントリを読み飛ばしていた。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 21:18:14 +09:00
harukin-expo-dev-envandClaude Opus 5.5 a666cb7d09 chore: リポジトリのSkillをClaude Codeからも使えるようにする
Codex向けの .agents/skills/ にある animate-expo / jr-shikoku-architecture /
jr-shikoku-data-model へ、.claude/skills/ から相対symlinkを張る。
正本は .agents/skills/ のまま。置き場所の運用をAGENTS.mdに追記。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 21:16:13 +09:00
harukin-expo-dev-env 7e50c30638 Merge commit '899e958d2fff0e0539397eedbcc6bd35caa53f5a' into develop 2026-09-30 21:14:42 +09:00
harukin-expo-dev-envandClaude Opus 5.5 899e958d2f fix: エラー画面の上余白をsafe areaに合わせ、スタックのパスを短縮する
実機確認で、タブ内のエラー画面のタイトルがステータスバーに重なっていた。

- 上下の余白をsafe areaのinsetから計算する。ルートはSafeAreaProviderの
  外側なので起動時のinsetで代替する
- スタック各行の端末内フルパスを「ファイル名:行:列」に短縮し、
  コピーした報告を読みやすくする

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 17:44:08 +09:00
harukin-expo-dev-envandClaude Opus 5.5 8b8d865ff3 feat: 研究ツールにErrorBoundaryのエラー画面テストを追加
描画エラーは意図的に起こせず、エラー画面を実機で確認する手段がなかった。

- 研究ツール(開発ビルドまたは管理者のみ表示)から、アプリ全体/走行位置/
  トップメニュー/運行情報のいずれかで次の描画時に例外を投げる
- 各AppErrorBoundary内の仕掛けが指定scopeのときだけ例外を投げ、
  捕捉時に解除するので再表示で復帰できる
- Sentryには error_boundary_test: true のtag付きで送信し、本物と区別する

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 17:30:08 +09:00
harukin-expo-dev-envandClaude Opus 5.5 0b62157483 docs: A-3の実施状況をバックログに反映
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 17:18:14 +09:00
harukin-expo-dev-envandClaude Opus 5.5 e1d79c0d9f refactor: @ts-ignoreを除去し、型の抑制をライブラリ境界の@ts-expect-errorだけにする (A-3)
- 抑制対象の型エラーがなかった7件を削除
- 駅時刻表の一覧3種から駅詳細シートを開くpayloadに必須のgoToを明示
  (goToは"NearTrainDiagramView"か否かだけで使われ、undefinedと挙動は同じ)
- RNEUIのListItem/Chevronの型不足4件は理由付きの@ts-expect-errorに置換
  (ライブラリ側で型が直れば検出できる)

挙動変更なし。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 17:17:54 +09:00
harukin-expo-dev-envandClaude Opus 5.5 7b8de698d1 refactor: ルートのApps.tsxをAppContainer.tsxへ改名
components/Apps.tsx(走行位置画面)と名前が衝突していたため、
中身のexport名に合わせて改名する。挙動変更なし。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 17:15:21 +09:00
harukin-expo-dev-envandClaude Opus 5.5 25b4dbcf56 chore: 未使用のpackage.json退避ファイルを削除し、current.txtを対応不要に記録
- package.json.before-remove-resolutions はどこからも参照されていない
- current.txt は check.sh のダイヤ更新チェックに使うため残す

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 17:12:13 +09:00
harukin-expo-dev-envandClaude Opus 5.5 1f0cf82568 chore: 動作していないGitHub Actionsの検証workflowを削除
主なremoteはgit.pcgf.ioで、このworkflowは一度も実行されていなかった。
CIがあると誤解されないよう削除する。検証は手元の yarn verify で行う。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 17:05:02 +09:00
harukin-expo-dev-envandClaude Opus 5.5 55d1953b29 docs: Web版の復活(W-1)を将来の目標としてバックログに記録
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 16:24:50 +09:00
harukin-expo-dev-envandClaude Opus 5.5 927641bd08 chore: 動作しないWeb版の実装・設定・依存を削除
Web版は起動直後に落ちる状態で、公式サイトへのWebView注入に依存する
中心機能もブラウザでは実現できないため、いったん削除する。
復活は自前APIへの移行後の目標としてバックログに記録。

- Web入口(index.ts)、trainMenu.web.tsx、service worker、web-build/、
  public/_redirects、pushWeb / web:export scriptを削除
- app.jsonのplatformsからwebを除外
- Platform.OS === "web" 分岐を削除(ネイティブでは通らないコード)
  - ndViewのtabPress listenerはWeb専用の処理だけだったため削除
- 依存 react-dom / react-native-web / @lottiefiles/dotlottie-react を削除
  (@expo/metro-runtimeは開発時のネイティブのログ転送に使うため残す)
- OSSライセンス一覧を再生成

yarn verify、expo export (android / ios) 成功。androidの同梱asset 260件は不変。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 16:24:50 +09:00
harukin-expo-dev-envandClaude Opus 5.5 c2f43114f6 feat: 描画エラー時に白画面ではなく復帰・エラー内容コピー画面を出す
ErrorBoundaryがなく、描画中の例外1つでアプリ全体が白画面になっていた。

- AppErrorBoundary: 例外をSentryへ送り、代わりの画面を固定色で描画する
  - ルート: 再読み込み(Updates.reloadAsync)
  - タブ(走行位置/トップメニュー/運行情報): 再表示。他のタブは使える
  - エラー内容・発生箇所・アプリ/端末情報・Sentry Event IDをコピー可能
- formatErrorReport: コピー用テキストの組み立てを純粋関数にしてテスト

イベントハンドラ・非同期処理・ネイティブのクラッシュは対象外。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 16:15:37 +09:00
harukin-expo-dev-envandClaude Opus 5.5 d08e8f6906 fix: エラー応答でUnyohub/Elesite/乗り継ぎデータを上書き保存しないようにする
response.okを確認せずjson()の結果をstateとストレージへ書いていたため、
サーバーがエラーJSONを返すと正常データが上書き・永続化されていた。

- fetchJsonArray: HTTPエラーと配列以外の応答を例外にする
- parseStoredJsonArray: 保存済みデータが配列でなければ読み込まない
  (過去に保存されたエラー応答で.filter等が落ちるのを防ぐ)
- useBusAndTrainDataの取得失敗を未処理のrejectionにしない

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 16:06:02 +09:00
harukin-expo-dev-envandClaude Opus 5.5 f9d7f21d83 docs: 乗り継ぎデータが更新されない問題(B-1)を保留中の既知の問題として記録
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 16:06:02 +09:00
harukin-expo-dev-envandClaude Opus 5.5 6f57ffad0e refactor: 2階層以上の相対importを@/エイリアスに統一
from / require / 動的import のうち ../../ 以上の相対パス106箇所(45ファイル)を
ルート基準の @/ に置換。挙動変更なし。

- yarn verify 成功
- expo export (android) 成功、同梱asset 260件のhashが変更前と一致

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 15:58:57 +09:00
harukin-expo-dev-envandClaude Opus 5.5 14d564b742 test: 公式位置編集URLの期待値をFrontendの実ルート(train-positions)に合わせる
b529360で実装側は /train-positions/:id/edit に変更されていたが、テストが
旧パスのまま残りyarn verifyが失敗していた。production/experimental/nightly
のFrontendバンドルで定義ルートが train-positions/:positionId/edit である
ことを確認済み。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 15:23:20 +09:00
harukin-expo-dev-envandClaude Opus 5.5 58d0e071fc docs: 急行・競技種別を対応不要項目としてバックログに記録
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BDYYpzQGabzZcAgz6NPKmK
2026-09-30 12:49:34 +09:00
harukin-expo-dev-envandClaude Opus 5.5 756a32356a fix: 急行・競技の型定義とWebView表示設定の不足を補完
- getTrainTypeのcolorStringに競技の色、trainTypeStringに「急行」を追加し、typecheckエラーを解消
- WebView注入用のTRAIN_TYPE_CONFIGにEXPRESS(急行)とCompetition(競技)を追加
- TRAIN_TYPE_CONFIGがtrainTypeIDを網羅しているかsatisfiesで型検査

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BDYYpzQGabzZcAgz6NPKmK
2026-09-30 12:44:34 +09:00
harukin-expo-dev-envandClaude Opus 5.5 6ffca91034 refactor: 未使用のnameToWidgetを削除
2024-05の静的分岐化以降参照されておらず、旧Info_Widgetキーも残っていたため削除。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BDYYpzQGabzZcAgz6NPKmK
2026-09-30 12:41:31 +09:00
harukin-expo-dev-envandClaude Opus 5.5 58feaa439b feat: 設定にオープンソースライセンス画面を追加
- scripts/generate-oss-licenses.mjs で dependencies の依存ツリーからライセンス一覧を生成(yarn licenses:generate)
- 同一ライセンス文の重複排除とMIT定型部分のテンプレート化で出力を約338KBに抑制
- 改変して同梱している expo-felica-reader を先頭に表示
- 一覧が dependencies と食い違う場合に yarn test で検出するテストを追加
- READMEに再生成手順を追記

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 12:38:47 +09:00
harukin-expo-dev-envandClaude Opus 5.5 cb5bdd6271 docs: READMEを現行仕様に更新し、expo-felica-readerの出典を明記
- READMEの技術スタック、セットアップ、構成、API、ディープリンク、ウィジェット、ビルド手順を現行コードに合わせて更新
- 怪レい列車ウィジェットを未公開(公開予定・データ取得先未整備)として記載
- ライセンス欄(非公開・All Rights Reserved)と開発者欄を記載し、貢献節を削除
- expo-felica-readerに元リポジトリ(7nohe/expo-felica-reader, MIT)のLICENSEを追加し、podspecの作者・homepageを修正

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 12:30:43 +09:00
harukin-expo-dev-env 5dfeb6362c feat: enhance train data handling with extended station management and improve nearby train logic 2026-09-29 11:38:56 +09:00
harukin-expo-dev-env 3db21846e2 fix: update version code to 7.2.2 2026-09-22 14:24:15 +09:00
harukin-expo-dev-env f657c7f223 Merge commit '3671427e5a15dcd6b97aa359a0ccc175048529b2' into develop 2026-09-22 14:07:32 +09:00
harukin-expo-dev-env 3671427e5a feat: add competition and express train types with corresponding data handling 2026-09-22 14:07:22 +09:00
harukin-expo-dev-env 6c8b7f0344 feat: enhance performance monitoring and caching in various components
- Added performance markers in `ndView.tsx` to track startup and rendering times.
- Implemented caching logic for custom train data in `useAllTrainDiagram.tsx` with timestamps for data readiness.
- Optimized station data retrieval in `useStationList.tsx` by creating indexes for faster access.
- Refactored train delay data handling in `useTrainDelayData.tsx` to improve cleanup on unmount.
- Introduced tests for shared in-flight requests and timetable cache functionality to ensure reliability.
2026-09-22 10:38:00 +09:00
harukin-expo-dev-env 91d49e1fb2 feat: conditionally render platform description in PositionBox component 2026-09-20 23:02:43 +09:00
harukin-expo-dev-env 32380471a4 refactor: replace howto navigation with generalWebView
- Removed the howto screen and replaced its usage with a generalWebView for displaying URLs.
- Introduced normalizeVehicleInfoUrl function to ensure valid URLs are passed to WebView.
- Updated various components and hooks to utilize the new generalWebView navigation.
- Refactored hooks to use useMemo for performance improvements and reduced unnecessary re-renders.
- Cleaned up unused state and effects in several hooks related to train data and positions.
- Updated type definitions to reflect the removal of howto parameters.
2026-09-20 22:58:32 +09:00
harukin-expo-dev-env 671de5255e feat: add icons to settings options for improved UI clarity 2026-09-18 20:59:22 +09:00
harukin-expo-dev-env 12bc621d25 feat: add connection status monitoring with health checks
- Implemented connection status checks in `connectionStatus.ts` to monitor various services.
- Created a client for fetching health check data in `connectionStatusClient.ts`.
- Added comprehensive tests for health check functionality in `connectionStatus.test.ts`, covering various scenarios and payload interpretations.
2026-09-18 20:46:10 +09:00
202 changed files with 8410 additions and 2153 deletions
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/animate-expo
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/jr-shikoku-architecture
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/jr-shikoku-data-model
-28
View File
@@ -1,28 +0,0 @@
name: Verify
on:
pull_request:
permissions:
contents: read
jobs:
verify:
name: Typecheck, lint, and unit tests
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Use the project Node version
uses: actions/setup-node@v4
with:
node-version: 24.9.0
cache: yarn
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Required verification
run: yarn verify
+2 -1
View File
@@ -1,7 +1,8 @@
{
"mcpServers": {
"Sentry": {
"type": "http",
"url": "https://mcp.sentry.dev/mcp/xprocess-m5/jr-shikoku-unofficial-apps"
}
}
}
}
+2 -1
View File
@@ -10,7 +10,8 @@
"Read(//home/ubuntu/.qwen/debug/**)",
"Bash(curl *)",
"mcp__sentry__search_events",
"mcp__sentry__find_organizations"
"mcp__sentry__find_organizations",
"Skill(jr-shikoku-architecture)"
]
}
}
+5
View File
@@ -18,6 +18,11 @@ Skillの例や推奨と実際のプロジェクト構成が衝突する場合は
- 導入元は [emilkowalski/skills](https://github.com/emilkowalski/skills) の `animate-expo`、確認時点の固定コミットは `d23d7f88a2e21c9e4b1418c7abe420f5c1052ba7`(2026-08-29)です。今回導入するSkillは `animate-expo` の2ファイルだけです。
- `animate`、`ask-sonner`、`pick-ui-library` など他のSkillは、このリポジトリへ導入済みとは扱いません。
## Skillの置き場所
- リポジトリのSkillの正本は `.agents/skills/` です(Codexが読む場所)。
- Claude Code向けに、`.claude/skills/` に `.agents/skills/` の各Skillへの相対symlinkを置いています。Skillを編集するときは `.agents/skills/` 側を変更し、Skillを追加したときは `.claude/skills/` にもsymlinkを追加してください。
## 現在の構成(current valuesは正本から確認)
- 現行の主要世代は Expo SDK 57、React Native 0.86系、React 19系です。patch/minorをこのファイルへ固定せず、正確な値は `package.json` と `yarn.lock` から確認してください。
+18 -1
View File
@@ -7,8 +7,15 @@ import { useAppLifecycleObservability } from "./lib/observability/useAppLifecycl
import { useAppBootstrap } from "./lib/useAppBootstrap";
import { useStartupDeepLinkNavigation } from "./lib/useStartupDeepLinkNavigation";
import { AppRootLayout } from "./components/AppRootLayout";
import { AppErrorBoundary } from "./components/AppErrorBoundary";
import {
markStartup,
markStartupOnce,
scheduleStartupReports,
} from "./lib/devStartupPerformance";
initializeSentry();
markStartupOnce("app-module-ready", "app module ready");
LogBox.ignoreLogs([
"ViewPropTypes will be removed",
@@ -35,9 +42,19 @@ if (Platform.OS === "android") {
}
export default wrapWithSentry(function App() {
markStartupOnce("app-render", "App render");
scheduleStartupReports();
useAppLifecycleObservability({ nativeScreensMode, rootTabSceneMode });
useAppBootstrap();
useStartupDeepLinkNavigation();
return <AppRootLayout />;
React.useEffect(() => {
markStartup("App effect committed");
}, []);
return (
<AppErrorBoundary scope="root">
<AppRootLayout />
</AppErrorBoundary>
);
});
+207 -118
View File
@@ -18,6 +18,7 @@ import TNDView from "./ndView";
import { initIcon } from "./lib/initIcon";
import { Top } from "./Top";
import { MenuPage } from "./MenuPage";
import { AppErrorBoundary } from "./components/AppErrorBoundary";
import "./components/ActionSheetComponents/sheets";
import { rootNavigationRef } from "./lib/rootNavigation";
import WebView from "react-native-webview";
@@ -26,10 +27,17 @@ import type { RootTabParamList } from "@/types/navigation";
import { useThemeColors } from "./lib/theme";
import { telemetry } from "./lib/observability/telemetry";
import { useAppContainerController } from "./lib/useAppContainerController";
import { useCurrentTrainPolling } from "./stateBox/useCurrentTrain";
import {
getTabBarAppearance,
TabBarBackground,
} from "./components/AppContainer/TabBarBackground";
import {
markStartup,
markStartupOnce,
markStartupState,
} from "./lib/devStartupPerformance";
import { PositionsStartupReadyContext } from "./lib/positionsStartupContext";
const startupPreloadTelemetry = telemetry.scope("startup.preload");
const positionsRootTelemetry = telemetry.scope("positions.root");
@@ -38,27 +46,24 @@ const operationRootTelemetry = telemetry.scope("operation.root");
const Tab = createBottomTabNavigator<RootTabParamList>();
const HiddenStartupPreloadWebViews = ({
shouldPreloadPositions,
shouldPreloadInformation,
onInformationLoadStart,
onInformationLoadSettled,
}: {
shouldPreloadPositions: boolean;
shouldPreloadInformation: boolean;
onInformationLoadStart: () => void;
onInformationLoadSettled: () => void;
}) => {
const [positionsLoaded, setPositionsLoaded] = useState(false);
const [informationLoaded, setInformationLoaded] = useState(false);
useEffect(() => {
telemetry.context("startup_hidden_preload", {
shouldPreloadPositions,
shouldPreloadInformation,
positionsLoaded,
informationLoaded,
});
}, [
informationLoaded,
positionsLoaded,
shouldPreloadInformation,
shouldPreloadPositions,
]);
const hiddenStyle = useMemo(
@@ -74,34 +79,26 @@ const HiddenStartupPreloadWebViews = ({
[],
);
const showPositions = shouldPreloadPositions && !positionsLoaded;
const showInformation = shouldPreloadInformation && !informationLoaded;
if (!showPositions && !showInformation) return null;
useEffect(() => {
markStartupState(
"hidden-startup-preload-state",
"hidden startup preload state",
{
showInformation,
informationLoaded,
},
);
}, [
informationLoaded,
showInformation,
]);
if (!showInformation) return null;
return (
<View pointerEvents="none" style={hiddenStyle}>
{showPositions ? (
<WebView
source={{ uri: "https://train.jr-shikoku.co.jp/" }}
originWhitelist={["https://train.jr-shikoku.co.jp"]}
javaScriptEnabled
setSupportMultipleWindows={false}
onLoadStart={() => {
setAppLifecycleWebViewActive("startup_hidden_positions", true);
startupPreloadTelemetry.info("positions hidden preload loadStart");
}}
onLoadEnd={() => {
setAppLifecycleWebViewActive("startup_hidden_positions", false);
startupPreloadTelemetry.info("positions hidden preload loadEnd");
setPositionsLoaded(true);
}}
onError={() => {
setAppLifecycleWebViewActive("startup_hidden_positions", false);
startupPreloadTelemetry.error("positions hidden preload error");
}}
/>
) : null}
{showInformation ? (
<WebView
source={{ uri: "https://www.jr-shikoku.co.jp/info/" }}
@@ -109,15 +106,18 @@ const HiddenStartupPreloadWebViews = ({
javaScriptEnabled
setSupportMultipleWindows={false}
onLoadStart={() => {
onInformationLoadStart();
setAppLifecycleWebViewActive("startup_hidden_operation", true);
startupPreloadTelemetry.info("operation hidden preload loadStart");
}}
onLoadEnd={() => {
onInformationLoadSettled();
setAppLifecycleWebViewActive("startup_hidden_operation", false);
startupPreloadTelemetry.info("operation hidden preload loadEnd");
setInformationLoaded(true);
}}
onError={() => {
onInformationLoadSettled();
setAppLifecycleWebViewActive("startup_hidden_operation", false);
startupPreloadTelemetry.error("operation hidden preload error");
}}
@@ -143,6 +143,11 @@ const DeferredPositionsRoot = (props: DeferredRootProps) => {
const [hasActivated, setHasActivated] = useState(false);
useEffect(() => {
markStartupState("positions-root-gate", "positions root gate state", {
focused: isRootFocused,
activated: hasActivated,
shouldActivate,
});
telemetry.context("positions_root_gate", {
focused: isRootFocused,
activated: hasActivated,
@@ -152,12 +157,19 @@ const DeferredPositionsRoot = (props: DeferredRootProps) => {
useEffect(() => {
if (!shouldActivate || hasActivated) return;
markStartup("positions root activation scheduled", {
focused: isRootFocused,
});
positionsRootTelemetry.breadcrumb({
message: "positions root activation",
data: {
reason: "first_root_visit",
},
});
markStartupOnce(
"positions-root-activated",
"positions root activation committed",
);
setHasActivated(true);
}, [hasActivated, shouldActivate]);
@@ -165,7 +177,11 @@ const DeferredPositionsRoot = (props: DeferredRootProps) => {
return <View style={{ flex: 1 }} />;
}
return <Top />;
return (
<AppErrorBoundary scope="positions">
<Top />
</AppErrorBoundary>
);
};
const PositionsTabScreen = memo(() => {
@@ -186,6 +202,11 @@ const DeferredInformationRoot = (props: DeferredRootProps) => {
const [hasActivated, setHasActivated] = useState(false);
useEffect(() => {
markStartupState("operation-root-gate", "operation root gate state", {
focused: isRootFocused,
activated: hasActivated,
shouldActivate,
});
telemetry.context("operation_root_gate", {
focused: isRootFocused,
activated: hasActivated,
@@ -195,12 +216,19 @@ const DeferredInformationRoot = (props: DeferredRootProps) => {
useEffect(() => {
if (!shouldActivate || hasActivated) return;
markStartup("operation root activation scheduled", {
focused: isRootFocused,
});
operationRootTelemetry.breadcrumb({
message: "operation root activation",
data: {
reason: isRootFocused ? "first_focus" : "background_prewarm",
},
});
markStartupOnce(
"operation-root-activated",
"operation root activation committed",
);
setHasActivated(true);
}, [hasActivated, isRootFocused, shouldActivate]);
@@ -208,9 +236,19 @@ const DeferredInformationRoot = (props: DeferredRootProps) => {
return <View style={{ flex: 1 }} />;
}
return <TNDView />;
return (
<AppErrorBoundary scope="information">
<TNDView />
</AppErrorBoundary>
);
};
const MenuTabScreen = () => (
<AppErrorBoundary scope="topMenu">
<MenuPage />
</AppErrorBoundary>
);
const InformationTabScreen = memo(() => {
const { shouldActivate, isRootFocused } = useContext(
InformationRootGateContext,
@@ -225,6 +263,7 @@ const InformationTabScreen = memo(() => {
InformationTabScreen.displayName = "InformationTabScreen";
export function AppContainer() {
markStartupOnce("app-container-render", "AppContainer render");
const controller = useAppContainerController();
const {
areaInfo,
@@ -235,14 +274,47 @@ export function AppContainer() {
startupInitialRoute,
hasVisitedPositions,
hasVisitedInformation,
positionsInitialLoadReady,
currentRootRoute,
fontLoaded,
startupIconFontsLoaded,
shouldPreloadPositions,
shouldPreloadInformation,
handlePositionsInitialLoadReady,
handlePositionsStackReady,
handlePositionsWebViewLoadStarted,
handleOperationWebViewLoadStarted,
handleOperationWebViewLoadSettled,
positionsStackReady,
positionsWebViewLoadStarted,
operationWebViewPreloadActive,
operationWebViewLoadStarted,
operationWebViewLoadSettled,
} = controller;
// トップメニューの列車情報だけは、画面のmountや走行位置WebViewを待たず
// 既存のCurrentTrainProviderを購読して初回取得を開始する。
useCurrentTrainPolling(
startupInitialRoute === "topMenu" &&
(currentRootRoute === null || currentRootRoute === "topMenu"),
);
const { isDark } = useThemeColors();
const operationWebViewPreloadPending =
operationWebViewPreloadActive;
useEffect(() => {
markStartupState("app-container-gate", "AppContainer gate state", {
fontLoaded,
startupIconFontsLoaded,
startupInitialRoute,
navigationReady: controller.navigationReady,
});
}, [
controller.navigationReady,
fontLoaded,
startupIconFontsLoaded,
startupInitialRoute,
]);
const linking = {
prefixes: ["jrshikoku://"],
@@ -284,7 +356,7 @@ export function AppContainer() {
tabBarIcon: initIcon(icon as any, iconFamily, tabBarBadge, isInfo),
},
});
if (!fontLoaded || !startupIconFontsLoaded || !startupInitialRoute) {
if (!fontLoaded || !startupInitialRoute) {
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<ActivityIndicator size="large" />
@@ -299,102 +371,119 @@ export function AppContainer() {
onReady={controller.handleNavigationReady}
onStateChange={controller.handleNavigationStateChange}
>
<PositionsRootGateContext.Provider
<PositionsStartupReadyContext.Provider
value={{
shouldActivate: hasVisitedPositions,
isRootFocused: currentRootRoute === "positions",
stackReady: positionsStackReady,
webViewLoadStarted: positionsWebViewLoadStarted,
initialLoadReady: positionsInitialLoadReady,
operationWebViewPreloadPending,
operationWebViewLoadStarted,
operationWebViewLoadSettled,
notifyInitialLoadReady: handlePositionsInitialLoadReady,
notifyStackReady: handlePositionsStackReady,
notifyWebViewLoadStarted: handlePositionsWebViewLoadStarted,
notifyOperationWebViewLoadStarted: handleOperationWebViewLoadStarted,
notifyOperationWebViewLoadSettled: handleOperationWebViewLoadSettled,
}}
>
<InformationRootGateContext.Provider
<PositionsRootGateContext.Provider
value={{
shouldActivate: hasVisitedInformation,
isRootFocused: currentRootRoute === "information",
shouldActivate: hasVisitedPositions,
isRootFocused: currentRootRoute === "positions",
}}
>
<Tab.Navigator
id="rootTabs"
detachInactiveScreens={false}
initialRouteName={startupInitialRoute}
screenListeners={({ route }) => ({
tabPress: (event: EventArg<"tabPress", true>) => {
controller.handleRootTabPress(route.name, event);
},
focus: () => {
controller.handleRootTabFocus(route.name);
},
blur: () => {
controller.handleRootTabBlur(route.name);
},
state: (event) => {
controller.handleRootTabState(route.name, event.type);
},
})}
screenOptions={({ route }) => {
const tabBarBackgroundProps = {
routeName: route.name,
selectedLine,
isDark,
isExtraWindowOpen,
};
const {
defaultBg,
activeTintColor,
inactiveTintColor,
} = getTabBarAppearance(tabBarBackgroundProps);
return {
lazy: false,
freezeOnBlur: false,
sceneContainerStyle: { backgroundColor: defaultBg },
tabBarActiveTintColor: activeTintColor,
tabBarInactiveTintColor: inactiveTintColor,
tabBarStyle: { backgroundColor: "transparent" },
tabBarBackground: () => (
<TabBarBackground {...tabBarBackgroundProps} />
),
};
<InformationRootGateContext.Provider
value={{
shouldActivate: hasVisitedInformation,
isRootFocused: currentRootRoute === "information",
}}
>
<Tab.Screen
{...getTabProps(
"positions",
"走行位置",
"bar-chart",
"AntDesign",
)}
component={PositionsTabScreen}
listeners={{
tabPress: controller.activatePositionsFromTabPress,
focus: controller.activatePositionsFromFocus,
blur: controller.preservePositionsOnBlur,
<Tab.Navigator
id="rootTabs"
detachInactiveScreens={false}
initialRouteName={startupInitialRoute}
screenListeners={({ route }) => ({
tabPress: (event: EventArg<"tabPress", true>) => {
controller.handleRootTabPress(route.name, event);
},
focus: () => {
controller.handleRootTabFocus(route.name);
},
blur: () => {
controller.handleRootTabBlur(route.name);
},
state: (event) => {
controller.handleRootTabState(route.name, event.type);
},
})}
screenOptions={({ route }) => {
const tabBarBackgroundProps = {
routeName: route.name,
selectedLine,
isDark,
isExtraWindowOpen,
};
const {
defaultBg,
activeTintColor,
inactiveTintColor,
} = getTabBarAppearance(tabBarBackgroundProps);
return {
lazy: false,
freezeOnBlur: false,
sceneContainerStyle: { backgroundColor: defaultBg },
tabBarActiveTintColor: activeTintColor,
tabBarInactiveTintColor: inactiveTintColor,
tabBarStyle: { backgroundColor: "transparent" },
tabBarBackground: () => (
<TabBarBackground {...tabBarBackgroundProps} />
),
};
}}
/>
<Tab.Screen
{...getTabProps("topMenu", "トップメニュー", "radio", "Ionicons")}
component={MenuPage}
/>
>
<Tab.Screen
{...getTabProps(
"positions",
"走行位置",
"bar-chart",
"AntDesign",
)}
component={PositionsTabScreen}
listeners={{
tabPress: controller.activatePositionsFromTabPress,
focus: controller.activatePositionsFromFocus,
blur: controller.preservePositionsOnBlur,
}}
/>
<Tab.Screen
{...getTabProps("topMenu", "トップメニュー", "radio", "Ionicons")}
component={MenuTabScreen}
/>
<Tab.Screen
{...getTabProps(
"information",
"運行情報",
"train",
"Ionicons",
areaInfo ? areaIconBadgeText : undefined,
isInfo,
)}
component={InformationTabScreen}
listeners={{
tabPress: controller.activateInformationFromTabPress,
focus: controller.activateInformationFromFocus,
blur: controller.preserveInformationOnBlur,
}}
/>
</Tab.Navigator>
</InformationRootGateContext.Provider>
</PositionsRootGateContext.Provider>
<Tab.Screen
{...getTabProps(
"information",
"運行情報",
"train",
"Ionicons",
areaInfo ? areaIconBadgeText : undefined,
isInfo,
)}
component={InformationTabScreen}
listeners={{
tabPress: controller.activateInformationFromTabPress,
focus: controller.activateInformationFromFocus,
blur: controller.preserveInformationOnBlur,
}}
/>
</Tab.Navigator>
</InformationRootGateContext.Provider>
</PositionsRootGateContext.Provider>
</PositionsStartupReadyContext.Provider>
<HiddenStartupPreloadWebViews
shouldPreloadPositions={shouldPreloadPositions}
shouldPreloadInformation={shouldPreloadInformation}
onInformationLoadStart={handleOperationWebViewLoadStarted}
onInformationLoadSettled={handleOperationWebViewLoadSettled}
/>
</NavigationContainer>
);
+36 -11
View File
@@ -119,6 +119,7 @@ export default ({ route }) => {
uri,
useExitButton = true,
importRecordingDownloads = false,
onExit,
} = route.params;
const { goBack } = useNavigation();
const { fixed } = useThemeColors();
@@ -132,6 +133,8 @@ export default ({ route }) => {
DEFAULT_JR_DATA_SYSTEM_ENV,
);
const [resolvedUri, setResolvedUri] = React.useState("");
const [webViewSourceUri, setWebViewSourceUri] = React.useState("");
const resolvedUriRef = React.useRef("");
const [isEnvironmentReady, setIsEnvironmentReady] = React.useState(false);
const hasAlerted = React.useRef(false);
const [isLoading, setIsLoading] = React.useState(true);
@@ -148,6 +151,11 @@ export default ({ route }) => {
const blankCountRef = React.useRef(0);
const remount = React.useCallback(() => {
// 現在のURLは復元用に使うが、通常のナビゲーションではsourceへ戻さない。
// sourceの更新はWebViewの再読込になるため、フィルター操作中に行うと状態が消える。
if (resolvedUriRef.current) {
setWebViewSourceUri(resolvedUriRef.current);
}
lastPongAt.current = Date.now(); // remount直後に誤検知しないようリセット
maxBodyLenRef.current = 0;
blankCountRef.current = 0;
@@ -172,11 +180,12 @@ export default ({ route }) => {
const nextUri = importRecordingDownloads
? rawUri
: rewriteJrDataSystemUrl(rawUri, nextEnvironment);
setResolvedUri(
importRecordingDownloads
? nextUri
: withJrDataSystemIconQuery(nextUri, iconSetting),
);
const initialUri = importRecordingDownloads
? nextUri
: withJrDataSystemIconQuery(nextUri, iconSetting);
resolvedUriRef.current = initialUri;
setResolvedUri(initialUri);
setWebViewSourceUri(initialUri);
setIsEnvironmentReady(true);
};
@@ -190,6 +199,9 @@ export default ({ route }) => {
}, [iconSetting, uri, importRecordingDownloads]);
const handleReload = () => {
if (resolvedUriRef.current) {
setWebViewSourceUri(resolvedUriRef.current);
}
lastPongAt.current = Date.now();
setHasError(false);
hasErrorRef.current = false;
@@ -219,7 +231,9 @@ export default ({ route }) => {
stack.pop();
const previousUrl = stack[stack.length - 1];
if (!previousUrl) return false;
resolvedUriRef.current = previousUrl;
setResolvedUri(previousUrl);
setWebViewSourceUri(previousUrl);
syncCanGoBack(false);
remount();
return true;
@@ -233,6 +247,14 @@ export default ({ route }) => {
return handlePseudoBack();
}, [handlePseudoBack]);
const handleExit = React.useCallback(() => {
try {
void onExit?.();
} finally {
goBack();
}
}, [goBack, onExit]);
const handleImportRecordingText = React.useCallback(
async (content: string) => {
try {
@@ -277,20 +299,20 @@ export default ({ route }) => {
if (canGoBack) {
if (handleWebViewBack()) return true;
}
goBack();
handleExit();
return true;
};
const subscription = BackHandler.addEventListener("hardwareBackPress", onHardwareBack);
return () => subscription.remove();
}, [canGoBack, goBack, handleWebViewBack])
}, [canGoBack, handleExit, handleWebViewBack])
);
return (
<View style={{ height: "100%", backgroundColor: fixed.primary }}>
{isEnvironmentReady && (
<WebView
key={webViewKey}
source={{ uri: resolvedUri }}
source={{ uri: webViewSourceUri }}
contentMode="mobile"
allowsBackForwardNavigationGestures
setSupportMultipleWindows={false}
@@ -354,7 +376,9 @@ export default ({ route }) => {
iconSetting,
);
if (rewrittenUrl !== request.url) {
resolvedUriRef.current = rewrittenUrl;
setResolvedUri(rewrittenUrl);
setWebViewSourceUri(rewrittenUrl);
return false;
}
}
@@ -375,6 +399,7 @@ export default ({ route }) => {
rewriteJrDataSystemUrl(navState.url, selectedEnvironment),
iconSetting,
);
resolvedUriRef.current = nextUrl;
setResolvedUri((current) => (current === nextUrl ? current : nextUrl));
if (nextUrl !== "https://unyohub.2pd.jp/integration/succeeded.php") {
pushHistoryEntry(nextUrl);
@@ -448,7 +473,7 @@ export default ({ route }) => {
return;
}
if (type === "back") return handleWebViewBack();
if (type === "windowClose") return goBack();
if (type === "windowClose") return handleExit();
}}
/>
)}
@@ -466,13 +491,13 @@ export default ({ route }) => {
<Text style={wvStyles.reloadButtonText}>再読み込み</Text>
</TouchableOpacity>
{useExitButton && (
<TouchableOpacity style={wvStyles.backButton} onPress={goBack}>
<TouchableOpacity style={wvStyles.backButton} onPress={handleExit}>
<Text style={wvStyles.backButtonText}>閉じる</Text>
</TouchableOpacity>
)}
</View>
)}
{useExitButton && !hasError && <BigButton onPress={goBack} string="閉じる" />}
{useExitButton && !hasError && <BigButton onPress={handleExit} string="閉じる" />}
</View>
);
};
+38 -6
View File
@@ -8,7 +8,6 @@ import { AS } from "@/storageControl";
import { STORAGE_KEYS } from "@/constants";
import { logger } from "@/utils/logger";
import TrainBase from "@/components/trainbaseview";
import HowTo from "@/howto";
import { Menu } from "@/menu";
import News from "@/components/news";
import Setting from "@/components/Settings/settings";
@@ -22,6 +21,11 @@ import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs";
import GeneralWebView from "@/GeneralWebView";
import { StationDiagramView } from "@/components/StationDiagram/StationDiagramView";
import { telemetry } from "@/lib/observability/telemetry";
import {
markStartup,
markStartupOnce,
markStartupState,
} from "@/lib/devStartupPerformance";
import type { StackNavigationProp } from "@react-navigation/stack";
import type {
RootTabParamList,
@@ -31,6 +35,7 @@ const Stack = createStackNavigator<TopMenuStackParamList>();
const topMenuScreenTelemetry = telemetry.scope("topMenu.screen");
export function MenuPage() {
markStartupOnce("top-menu-render", "top menu render");
const { height, width } = useWindowDimensions();
const { verticalScale } = useResponsive();
const tabBarHeight = useBottomTabBarHeight();
@@ -42,6 +47,7 @@ export function MenuPage() {
const bgColor = isDark ? "#1c1c1e" : "#ffffff";
useEffect(() => {
markStartupOnce("top-menu-mounted", "top menu mounted");
topMenuScreenTelemetry.info("top menu mounted");
return () => {
topMenuScreenTelemetry.info("top menu unmounted");
@@ -49,6 +55,9 @@ export function MenuPage() {
}, []);
useEffect(() => {
markStartupState("top-menu-focus", "top menu focus state", {
focused: isFocused,
});
topMenuScreenTelemetry.breadcrumb({
message: isFocused ? "top menu focused" : "top menu blurred",
data: {
@@ -60,16 +69,40 @@ export function MenuPage() {
useEffect(() => {
//ニュース表示
markStartup("top menu startup storage checks start");
AS.getItem(STORAGE_KEYS.NEWS_STATUS)
.then((d) => {
if (d !== news) navigation.navigate("topMenu", { screen: "news" });
markStartup("top menu news status read end", {
hasValue: d !== null && d !== undefined,
isCurrent: d === news,
});
if (d !== news) {
markStartup("top menu startup navigation attempt", {
target: "news",
reason: "news_status",
});
navigation.navigate("topMenu", { screen: "news" });
}
})
.catch(() => navigation.navigate("topMenu", { screen: "news" }));
.catch(() => {
markStartup("top menu news status read error");
markStartup("top menu startup navigation attempt", {
target: "news",
reason: "news_status_error",
});
navigation.navigate("topMenu", { screen: "news" });
});
AS.getItem(STORAGE_KEYS.ICON_SETTING)
.then((isSetIcon) => {
markStartup("top menu icon setting read end", {
hasValue: isSetIcon !== null && isSetIcon !== undefined,
});
if (isSetIcon === "true") SheetManager.show("TrainIconUpdate");
})
.catch((error) => logger.error("Error fetching icon setting:", error));
.catch((error) => {
markStartup("top menu icon setting read error");
logger.error("Error fetching icon setting:", error);
});
return undefined;
}, []);
@@ -122,7 +155,7 @@ export function MenuPage() {
}
}
scrollRef.current?.scrollTo({
y: mapHeightRef.current - verticalScale(80),
y: Math.max(0, mapHeightRef.current - verticalScale(80)),
animated: true,
});
setMapMode(false);
@@ -209,7 +242,6 @@ export function MenuPage() {
options={{ ...optionData, gestureEnabled: false }}
component={AllTrainDiagramView}
/>
<Stack.Screen name="howto" options={optionData} component={HowTo} />
<Stack.Screen
name="generalWebView"
options={optionData}
+147 -56
View File
@@ -1,73 +1,109 @@
# JR四国 列車位置情報アプリ
JR四国の列車リアルタイム位置情報を表示するReact Nativeアプリケーション。
JR四国の列車リアルタイム位置情報を表示するReact Nativeアプリケーション(非公式)。
## 🚀 技術スタック
- **フレームワーク**: React Native (Expo SDK 52)
> 正確なバージョンは宣言値を `package.json`、解決値を `yarn.lock` で確認してください。
- **フレームワーク**: Expo SDK 57 / React Native 0.86系 / React 19系(New Architecture)
- **言語**: TypeScript / JavaScript
- **状態管理**: React Context API + カスタムフック
- **ストレージ**: AsyncStorage
- **地図表示**: WebView + JavaScript Injection
- **ナビゲーション**: React Navigation 7(bottom tabs + stack。Expo Routerは未使用)
- **状態管理**: React Context API + カスタムフック(`stateBox/`)
- **ストレージ**: AsyncStorage、expo-sqlite
- **地図表示**: WebView + JavaScript Injection、react-native-maps
- **シート**: react-native-actions-sheet、@gorhom/bottom-sheet
- **日付処理**: dayjs
- **アニメーション**: react-native-reanimated
- **アニメーション / ジェスチャー**: react-native-reanimated、react-native-gesture-handler(v2系)
- **監視**: Sentry
- **ウィジェット**: react-native-android-widget(Android)、`@bacons/apple-targets` によるWidgetKit拡張(iOS)
## 📦 プロジェクト構造
```
jrshikoku/
├── components/ # Reactコンポーネント
│ ├── Apps/ # アプリケーション機能
│ ├── Apps/ # 走行位置画面
│ │ └── FixedPositionBox/
│ │ └── hooks/ # カスタムフック
│ ├── AndroidWidget/ # Androidウィジェット
│ ├── ActionSheetComponents/
│ ├── atom/ # 基本UIコンポーネント
│ ├── Menu/ # メニュー関連
│ ├── Settings/ # 設定画面
│ └── TrainMenu/ # 列車メニュー
├── stateBox/ # グローバル状態管理
├── stateBox/ # グローバル状態管理(Context + hooks)
├── lib/ # ユーティリティライブラリ
│ └── eachTrainInfoCoreLib/ # 列車情報処理
│ ├── domain/ # 駅・列車identityのdomain型
│ ├── eachTrainInfoCoreLib/ # 列車情報処理
│ ├── observability/ # Sentry・テレメトリ
│ ├── theme/ # カラーテーマ
│ └── webview/ # WebView注入用データ
├── constants/ # 定数定義
│ ├── intervals.ts # 時間間隔定数
│ ├── api.ts # APIエンドポイント
│ ├── storage.ts # StorageKeys
│ └── index.ts
├── modules/ # ローカルExpoモジュール
│ ├── expo-felica-reader/ # FeliCa読み取り(iOS/Android)
│ └── expo-live-activity/ # Live Activities(iOS)
├── targets/widget/ # iOS WidgetKit拡張
├── types/ # TypeScript型定義
├── utils/ # ユーティリティ関数
│ ├── logger.ts # ロギング
│ └── seUtils.ts # SE判定処理
├── tests/ # pure関数のテスト(node:test)
├── docs/ # 設計・changelog・検証記録
│ └── architecture/ # domain契約・ADR
├── assets/ # 静的リソース
└── config/ # 設定ファイル
```
`android/` と `ios/` はprebuildで生成されるため、gitでは管理していません。
## 🛠️ セットアップ
### 必要環境
- Node.js 18以上
- npm または yarn
- Expo CLI
- Node.js(Expo SDK 57が対応するLTS。`yarn expo:doctor` で確認)
- Yarn(lockfileは `yarn.lock` のみ)
- EAS CLI(ビルドする場合)
### インストール
### インストールと起動
```bash
# 依存関係のインストール
npm install
# 依存関係のインストール(postinstallでpatch-packageが実行されます)
yarn install
# 開発サーバー起動
npm start
# 開発サーバー起動(dev client)
yarn start
# iOS実行
npm run ios
# ネイティブビルドして実行
yarn ios
yarn android
```
# Android実行
npm run android
### 検証
# Web実行
npm run web
```bash
yarn typecheck # tsc --noEmit
yarn lint # expo lint
yarn test # tests/*.test.ts
yarn verify # 上記3つをまとめて実行
yarn expo:doctor # Expo依存関係の整合性チェック
```
### 依存ライブラリのライセンス一覧
`dependencies` を追加・更新したら、アプリ内の「オープンソースライセンス」画面で使う一覧を再生成してください。再生成を忘れると `yarn test` が失敗します。
```bash
yarn licenses:generate # assets/licenses/oss-licenses.json を更新
```
## 📝 開発ガイドライン
開発エージェント向けの詳細なルールは [AGENTS.md](./AGENTS.md)、domain契約は [docs/architecture/](./docs/architecture/) を参照してください。
### 定数の使用
```typescript
@@ -120,18 +156,31 @@ AsyncStorage.getItem(STORAGE_KEYS.BUS_AND_TRAIN);
### 列車位置追跡
- リアルタイム列車位置表示
- 遅延情報の表示
- 遅延情報の表示(遅延速報EX)
- 次駅・着駅の予測
- 列車追従・駅固定モード(iOSではLive Activitiesに対応)
- 走行位置の記録と再生
### 駅情報
- 時刻表表示
- 駅詳細情報
- お気に入り駅登録
- お気に入り駅登録・並び替え
### カスタマイズ
- アイコン設定
- レイアウト設定
- 通知設定
### 運行情報・通知
- JR四国の運行情報表示
- プッシュ通知(遅延速報EX、運行情報など)
- サウンドによる案内(β)
### ICカード
- FeliCa対応ICカードの残高・履歴読み取り
### カスタマイズ(設定画面)
- 起動時に表示する画面、レイアウト設定
- カラーテーマ(ライト / 端末依存 / ダーク)
- アイコン設定、ランチャーアイコン変更
- サウンド設定(β)、運行情報設定(β)
- 通知設定、情報ソース設定、接続ステータス
- ウィジェット設定(Android)
## 🔧 カスタムフック
@@ -144,14 +193,22 @@ AsyncStorage.getItem(STORAGE_KEYS.BUS_AND_TRAIN);
- `useTrainDataWithThrough`: 通過駅を含む列車データ生成
- `useDestinationStation`: 行先駅データの管理
### グローバル状態フック
### グローバル状態フック(`stateBox/`)
- `useAllTrainDiagram`: 全列車ダイヤデータ
- `useAreaInfo`: 運行情報スナップショット(エリア情報)
- `useBusAndTrainData`: バス・列車データ
- `useCurrentTrain`: 現在選択中の列車
- `useCurrentTrain`: 現在の列車位置データ
- `useDeviceOrientationChange`: 画面の向き
- `useElesite`: えれサイト運用データ
- `useFavoriteStation`: お気に入り駅
- `useNotifications`: プッシュ通知と通知タップ時のルーティング
- `useStationList`: 全駅リスト
- `useTopMenu`: トップメニュー状態
- `useTrainDelayData`: 遅延情報
- `useTrainMenu`: 列車メニュー状態
- `useUnyohub`: 鉄道運用Hub運用データ
- `useUserPosition`: ユーザーの現在地
## 📊 リファクタリング実績
@@ -165,17 +222,24 @@ AsyncStorage.getItem(STORAGE_KEYS.BUS_AND_TRAIN);
## 🚦 API エンドポイント
エンドポイントは `constants/api.ts` の `API_ENDPOINTS` に集約しています。一覧と各URLは同ファイルを参照してください。
```typescript
// constants/api.ts
// constants/api.ts(抜粋)
const BASE_URL = 'https://jr-shikoku-api-data-storage.haruk.in';
export const API_ENDPOINTS = {
TRAIN_DATA_API: 'https://api.haruk.in/dev/jrshikoku/trainList',
DIAGRAM_TODAY: 'https://api.haruk.in/dev/jrshikoku/diagram/today',
STATION_LIST: 'https://storage.haruk.in/s/station.json',
// ... 他8個
DIAGRAM_TODAY: `${BASE_URL}/tmp/diagram-today.json`,
OPERATION_INFO: `${BASE_URL}/operation-info/jr-shikoku/latest.json`,
DELAY_INFO: `${BASE_URL}/derived/delays/latest.json`,
CURRENT_POSITIONS: `${BASE_URL}/tmp/currentPositions.json`,
// ...
};
```
## � 外部からのアプリ起動(ディープリンク)
データ取得の全体像は [docs/data-fetch-map.md](./docs/data-fetch-map.md) を参照してください。
## 🔗 外部からのアプリ起動(ディープリンク)
本アプリは複数の方法で外部から起動・画面遷移できます。
@@ -185,14 +249,14 @@ export const API_ENDPOINTS = {
| URL | 遷移先 |
|---|---|
| `jrshikoku://open/felica` | FeliCa履歴ページ |
| `jrshikoku://open/felica`(`felicahistorypage` も可) | FeliCa履歴ページ |
| `jrshikoku://open/traininfo` | 遅延速報EX |
| `jrshikoku://open/operation` | 運行情報 |
| `jrshikoku://open/settings` | 設定 |
| `jrshikoku://open/topmenu` | トップメニュー |
| `jrshikoku://positions/apps` | 走行位置 |
URLの処理は `App.tsx` の `routeFromUrl` で実装されています。
URLパターンの定義は `lib/startupDeepLink.ts`、遷移処理は `lib/useStartupDeepLinkNavigation.ts` の `routeFromUrl` で実装されています。
### Androidウィジェット
@@ -204,15 +268,32 @@ URLの処理は `App.tsx` の `routeFromUrl` で実装されています。
| `JR_shikoku_apps_shortcut` | クイックアクセス(各機能へのショートカットタイル) |
| `JR_shikoku_felica_balance` | ICカード残高表示 |
| `JR_shikoku_info` | 運行情報 |
| `JR_shikoku_train_strange` | 怪レい列車 |
ウィジェットのタップ時は `jrshikoku://` スキームでアプリ内画面へ遷移します。
実装: `components/AndroidWidget/widget-task-handler.tsx`
配置可能なウィジェットは `app.json` の `react-native-android-widget` プラグイン設定で登録し、描画は `components/AndroidWidget/widget-task-handler.tsx` で行います。
#### 未公開: 怪レい列車ウィジェット(`JR_shikoku_train_strange`)
公開する予定はありますが、現在は未完成のため `app.json` に登録しておらず、ホーム画面には配置できません。
- `components/AndroidWidget/StrangeTrainWidget.tsx` と `widget-task-handler.tsx` に描画処理はありますが、表示内容は固定文の仮実装です。
- ウィジェットに表示するデータの取得先(エンドポイント)がまだ用意されていません。
- タップ先は仮で `jrshikoku://open/traininfo`(遅延速報EX)になっています。
- 公開時は、データ取得の実装、プレビュー画像(`assets/widgetResource/`)の追加、`app.json` への登録が必要です。登録はネイティブ設定の変更になるため、OTAではなく新しいビルドで配布する必要があります。
怪レい列車の通知(通知設定、通知アクション `strange-train`)は、ウィジェットとは別に実装されています。
### iOSウィジェット(WidgetKit)
WidgetKit拡張としてホーム画面ウィジェットを提供しています。Live Activities にも対応しています。
設定: `targets/widget/Info.plist`
WidgetKit拡張として、次のホーム画面ウィジェットを提供しています。
- ショートカット
- ICカード残高
- 遅延速報
- 運行情報
Live Activitiesは、駅固定(StationLock)と列車追従(TrainFollow)に対応しています。
実装: `targets/widget/`
### プッシュ通知
@@ -228,30 +309,40 @@ WidgetKit拡張としてホーム画面ウィジェットを提供していま
### NFC(FeliCa)
NFC-Fを利用した交通系ICカードの読み取りに対応しています(Android)。アプリ内からスキャンを開始する形式です。
実装: `modules/expo-felica-reader/`
NFC-Fを利用した交通系ICカードの読み取りに、iOS・Androidとも対応しています。アプリ内からスキャンを開始する形式です。
実装: `modules/expo-felica-reader/`([7nohe/expo-felica-reader](https://github.com/7nohe/expo-felica-reader) をもとに改変、MIT)
## �📱 ビルド
## 📱 ビルド
EASのprofileとchannelは `eas.json` で管理しています。主なprofileは次のとおりです。
| profile | 用途 |
|---|---|
| `development` | dev client(internal配布) |
| `preview` | 内部配布 |
| `production<version>` / `beta<version>` | バージョン別の本番・ベータ(例: `production7.2`、`beta7.2`) |
```bash
# APKビルド(Android)
eas build --platform android
# IPAビルド(iOS)
eas build --platform ios
eas build --platform android --profile <profile>
eas build --platform ios --profile <profile>
```
## 🤝 貢献
プルリクエストを歓迎します。大きな変更を行う場合は、まずissueを開いて変更内容を議論してください。
runtimeVersionの方針は [docs/architecture/adr/001-runtime-version-policy.md](./docs/architecture/adr/001-runtime-version-policy.md) を参照してください。
ネイティブ設定(`app.json` のプラグイン、permission、entitlement、ローカルモジュールなど)を変更した場合は、OTAではなく新しいビルドが必要です。
## 📄 ライセンス
[ライセンス情報をここに記載]
本リポジトリは非公開です。無断での複製・再配布を禁止します(All Rights Reserved)。
- 同梱しているフォント・音声・ロゴなどの素材は、それぞれの権利者の規約に従います。
- 依存ライブラリのライセンスは、各パッケージの記載に従います。一覧はアプリの「設定 > オープンソースライセンス」で確認できます。
- `modules/expo-felica-reader/` は、Daiki Urata氏の [expo-felica-reader](https://github.com/7nohe/expo-felica-reader) 0.1.0(MIT License)をもとに改変したものです。ライセンス表記は `modules/expo-felica-reader/LICENSE` を参照してください。
本アプリはJR四国とは無関係の非公式アプリです。「JR四国」などの名称・商標は各権利者に帰属します。
## 👤 開発者
[開発者情報をここに記載]
harukin/Xprocess
## 🔗 関連リンク
+39 -9
View File
@@ -5,33 +5,56 @@ import type { BottomTabNavigationProp } from "@react-navigation/bottom-tabs";
import { useColorScheme } from "react-native";
import Apps from "./components/Apps";
import TrainBase from "./components/trainbaseview";
import HowTo from "./howto";
import News from "./components/news";
import TrainMenu from "./components/trainMenu";
import { FavoriteList } from "./components/FavoriteList";
import { optionData } from "./lib/stackOption";
import { useCurrentTrainPolling, useCurrentTrainView } from "./stateBox/useCurrentTrain";
import {
useCustomTrainData,
useOperationLogs,
} from "./stateBox/useAllTrainDiagram";
import { useTrainMenu } from "./stateBox/useTrainMenu";
import { AS } from "./storageControl";
import { news } from "./config/newsUpdate";
import { InteractionManager, Linking, Platform, View } from "react-native";
import { InteractionManager, Platform, View } from "react-native";
import GeneralWebView from "./GeneralWebView";
import { StationDiagramView } from "@/components/StationDiagram/StationDiagramView";
import { positionsLifecycleRef, positionsStackNavRef } from "./lib/rootNavigation";
import { telemetry } from "./lib/observability/telemetry";
import {
markStartup,
markStartupOnce,
markStartupState,
} from "./lib/devStartupPerformance";
import { usePositionsStartupReady } from "./lib/positionsStartupContext";
import type { PositionsStackParamList, RootTabParamList } from "@/types/navigation";
const Stack = createStackNavigator<PositionsStackParamList>();
const positionsStackTelemetry = telemetry.scope("positions.stack");
export const Top = () => {
markStartupOnce("positions-stack-render", "positions Top render");
const { webview } = useCurrentTrainView();
const navigation = useNavigation<BottomTabNavigationProp<RootTabParamList>>();
const { navigate, addListener } = navigation;
const isTabFocused = useIsFocused();
const { notifyStackReady } = usePositionsStartupReady();
useCurrentTrainPolling(isTabFocused);
const { activateCustomTrainData } = useCustomTrainData();
const { activateOperationLogs } = useOperationLogs();
const isDark = useColorScheme() === "dark";
const bgColor = isDark ? "#1c1c1e" : "#ffffff";
const [hasActivatedStack, setHasActivatedStack] = useState(false);
useEffect(() => {
if (!isTabFocused) return;
activateCustomTrainData();
activateOperationLogs();
}, [
activateCustomTrainData,
activateOperationLogs,
isTabFocused,
]);
//地図用
const { mapSwitch } = useTrainMenu();
const mapSwitchRef = useRef(mapSwitch);
@@ -54,6 +77,7 @@ export const Top = () => {
}, []);
useEffect(() => {
markStartupOnce("positions-stack-mounted", "positions stack mounted");
positionsStackTelemetry.info("positions stack mounted");
return () => {
positionsStackTelemetry.info("positions stack unmounted");
@@ -62,6 +86,9 @@ export const Top = () => {
useEffect(() => {
if (hasActivatedStack) return;
markStartup("positions stack activation scheduled", {
focused: isTabFocused,
});
positionsStackTelemetry.breadcrumb({
message: "positions stack activation scheduled",
data: {
@@ -71,6 +98,10 @@ export const Top = () => {
let cancelled = false;
const activate = () => {
if (cancelled) return;
markStartupOnce(
"positions-stack-activated",
"positions stack activation committed",
);
positionsStackTelemetry.breadcrumb({
message: "positions stack activation committed",
data: {
@@ -78,6 +109,7 @@ export const Top = () => {
},
});
setHasActivatedStack(true);
notifyStackReady();
};
if (isTabFocused) {
@@ -95,9 +127,13 @@ export const Top = () => {
cancelled = true;
clearTimeout(timer);
};
}, [hasActivatedStack, isTabFocused]);
}, [hasActivatedStack, isTabFocused, notifyStackReady]);
useEffect(() => {
markStartupState("positions-stack-gate", "positions stack gate state", {
focused: isTabFocused,
activated: hasActivatedStack,
});
telemetry.context("positions_stack", {
focused: isTabFocused,
activated: hasActivatedStack,
@@ -131,11 +167,6 @@ export const Top = () => {
stackIndex: stackNav?.getState()?.index ?? null,
},
});
if (Platform.OS === "web") {
Linking.openURL("https://train.jr-shikoku.co.jp/");
setTimeout(() => navigate("topMenu", { screen: "menu" }), 100);
return;
}
if (!hasActivatedStack || !stackNav) {
e?.preventDefault?.();
positionsStackTelemetry.breadcrumb({
@@ -239,7 +270,6 @@ export const Top = () => {
options={{ ...optionData, gestureEnabled: false }}
component={StationDiagramView}
/>
<Stack.Screen name="howto" options={optionData} component={HowTo} />
<Stack.Screen
name="generalWebView"
options={optionData}
+1 -2
View File
@@ -5,8 +5,7 @@
"scheme": "jrshikoku",
"platforms": [
"ios",
"android",
"web"
"android"
],
"version": "7.2",
"userInterfaceStyle": "automatic",
File diff suppressed because one or more lines are too long
@@ -9,6 +9,7 @@ export const EachTrainInfo = ({ payload }: SheetProps<"EachTrainInfo">) => {
const actionSheetRef = useRef(null);
const maxHeight = useSheetMaxHeight();
const [sheetOpened, setSheetOpened] = useState(false);
useCurrentTrainPolling(sheetOpened);
const handleOpen = () => {
@@ -32,7 +32,6 @@ export const DataConnectedButton: FC<{
openTrainInfo(time);
}}
key={station + time}
>
<View style={{ flexDirection: "row", backgroundColor: colors.backgroundTertiary }}>
<View
@@ -1,9 +1,9 @@
import React, { FC } from "react";
import { View, Text, TouchableWithoutFeedback } from "react-native";
import dayjs from "dayjs";
import { Platform, View, Text, TouchableWithoutFeedback } from "react-native";
import type { Dayjs } from "dayjs";
import { setClockTime } from "@/lib/timeUtils";
import lineColorList from "../../../assets/originData/lineColorList";
import { trainDataType } from "@/lib/trainPositionTextArray";
import lineColorList from "@/assets/originData/lineColorList";
import type { trainDataType } from "@/lib/trainPositionTextArray";
import { getStopListColors } from "./colorScheme";
import {
isCanceledSe,
@@ -12,90 +12,89 @@ import {
parseSeString,
} from "@/utils/seUtils";
import type { SeTypes } from "@/types";
import { useThemeColors } from "@/lib/theme";
import { useResponsive } from "@/lib/responsive";
type seTypes =
| "発編"
| "着編"
| "通編"
| "頃編"
| "発"
| "着"
| "休編"
| "休発"
| "休着"
| "休発編"
| "休着編"
| "通休編"
| "通発休編"
| "通着休編"
| string;
type TrainDelay = trainDataType["delay"];
type currentTrainDataType = {
Index: number;
num: string;
delay: "入線" | number | undefined;
Pos: string;
PosNum: string | number;
Direction: number;
Type: string;
Line: string;
export type EachStopData = readonly [
station: string,
se: SeTypes,
time: string,
platformNum?: string,
];
type ResponsiveScale = {
fontScale: (size: number) => number;
moderateScale: (size: number, factor?: number) => number;
};
type StationInfo = { StationName: string; StationNumber: string | null };
type props = {
i: string;
stationNumberMap: ReadonlyMap<string, readonly string[]>;
parsedTrainData: readonly EachStopData[];
clockBase: Dayjs;
borderColor: string;
textOnPrimary: string;
isDark: boolean;
trainDelay: TrainDelay;
fontScale: ResponsiveScale["fontScale"];
moderateScale: ResponsiveScale["moderateScale"];
index: number;
stationList: StationInfo[][];
points: boolean;
currentTrainData?: currentTrainDataType;
openStationACFromEachTrainInfo?: (station: string) => void;
showThrew: boolean;
array: string[];
isNotService?: boolean;
};
export const EachStopList: FC<props> = ({
i,
const EachStopListComponent: FC<props> = ({
stationNumberMap,
parsedTrainData,
clockBase,
borderColor,
textOnPrimary,
isDark,
trainDelay,
fontScale,
moderateScale,
index,
stationList,
points,
currentTrainData,
openStationACFromEachTrainInfo,
showThrew,
array,
isNotService = false,
}) => {
const { colors: themeColors, isDark } = useThemeColors();
const { fontScale, moderateScale } = useResponsive();
const [station, se, time, platformNum] = i.split(",") as [
string,
seTypes,
string,
string
]; // 阿波池田,発,6:21,1
let beforeSameStationData = null;
// console.log({
// trainNum,
// index,
// points,
// //openStationACFromEachTrainInfo,
// showThrew,
// //array,
// isNotService,
// });
const currentData = parsedTrainData[index];
if (!currentData) return null;
const [station, se, time, platformNum] = currentData; // 阿波池田,発,6:21,1
let beforeSameStationData: EachStopData | null = null;
// 発(通常発・休発・休発編)の場合、前の着(通常着・休着・休着編)と統合する
if (se.includes("発")) {
if (index > 0) {
const beforeData = array[index - 1].split(",") as [string, seTypes, string];
const beforeData = parsedTrainData[index - 1];
// 前が着(通常着でも休着でも)の場合は統合
if (beforeData[0] == station && beforeData[1].includes("着")) {
if (beforeData && beforeData[0] == station && beforeData[1].includes("着")) {
beforeSameStationData = beforeData;
}
}
}
let afterSameStationData = null;
let afterSameStationData: EachStopData | null = null;
// 着(通常着・休着・休着編)の場合、次の発(通常発・休発・休発編)と統合される(非表示)
if (se.includes("着")) {
const afterData = array[index + 1]?.split(",") as [string, seTypes, string];
const afterData = parsedTrainData[index + 1];
// 次が発(通常発でも休発でも)なら、この着を非表示にして次の発で両方表示
if (afterData && afterData[0] == station && afterData[1].includes("発")) {
afterSameStationData = afterData;
return <></>;
}
}
if(station.includes(".")){
if (station.includes(".")) {
return <></>;
}
if (!showThrew) {
@@ -107,25 +106,11 @@ export const EachStopList: FC<props> = ({
if (se == "通発休編") return null;
if (se == "通着休編") return null;
}
const Stations = stationList
.map((a) => a.filter((d) => d.StationName == station))
.reduce((newArray, e) => newArray.concat(e), []);
/*Array [
Object {
"Station_JP": "佐古",
"StationNumber": "T01",
},
Object {
"Station_JP": "佐古",
"StationNumber": "B01",
},
] */
const StationNumbers = Stations.filter((d) => d.StationNumber != null).map(
(d) => d.StationNumber as string
);
const StationNumbers = stationNumberMap.get(station) ?? [];
const trimmedPlatformNum = platformNum?.trim();
const hasPlatformNum = !!trimmedPlatformNum;
const isPlatformNumNumeric = hasPlatformNum && /^\d+$/.test(trimmedPlatformNum);
const isPlatformNumNumeric =
hasPlatformNum && /^\d+$/.test(trimmedPlatformNum);
const platformDisplay = hasPlatformNum
? isPlatformNumNumeric
? parseInt(trimmedPlatformNum, 10) === 0
@@ -141,9 +126,7 @@ export const EachStopList: FC<props> = ({
const isCanceled = isCanceledSe(se);
const isCommunity = isCommunitySe(se);
const isDelayed =
currentTrainData?.delay !== undefined &&
currentTrainData?.delay !== "入線" &&
currentTrainData?.delay > 0;
trainDelay !== undefined && trainDelay !== "入線" && trainDelay > 0;
const colors = getStopListColors(
isThrough,
@@ -151,7 +134,7 @@ export const EachStopList: FC<props> = ({
isCanceled,
isDelayed,
isNotService,
isDark
isDark,
);
// 打ち消し線用の通常色(遅延していない時の色)
const normalColors = getStopListColors(
@@ -160,7 +143,7 @@ export const EachStopList: FC<props> = ({
isCanceled,
false,
isNotService,
isDark
isDark,
);
// beforeSameStationData用の色設定
@@ -179,7 +162,7 @@ export const EachStopList: FC<props> = ({
isCanceled,
isDelayed,
isNotService,
isDark
isDark,
);
const beforeNormalColors = getStopListColors(
beforeIsThrough,
@@ -187,19 +170,47 @@ export const EachStopList: FC<props> = ({
isCanceled,
false,
isNotService,
isDark
isDark,
);
beforeTimeTextColor = beforeColors.timeText;
beforeNormalTimeTextColor = beforeNormalColors.timeText;
}
const hasDoubleTime = !!beforeSameStationData || !!afterSameStationData;
const beforeTimeText = beforeSameStationData ? (
<TimeText
isDouble={hasDoubleTime}
isBefore={true}
delay={trainDelay}
se={beforeSameStationData[1]}
time={beforeSameStationData[2]}
textColor={beforeTimeTextColor}
normalTextColor={beforeNormalTimeTextColor}
clockBase={clockBase}
fontScale={fontScale}
moderateScale={moderateScale}
/>
) : null;
const mainTimeText = (
<TimeText
isDouble={hasDoubleTime}
delay={trainDelay}
se={se}
time={time}
textColor={colors.timeText}
normalTextColor={normalColors.timeText}
clockBase={clockBase}
fontScale={fontScale}
moderateScale={moderateScale}
/>
);
return (
<TouchableWithoutFeedback
onPress={() =>
openStationACFromEachTrainInfo &&
openStationACFromEachTrainInfo(station)
}
key={station + se + time}
>
<View
style={{
@@ -214,50 +225,56 @@ export const EachStopList: FC<props> = ({
position: "relative",
marginHorizontal: 15,
flexDirection: "row",
height: "101%",
alignSelf: "stretch",
}}
>
{StationNumbers.map((stn, index) => (
<StationNumbersBox stn={stn} se={se} key={index} />
))}
>
{StationNumbers.map((stn) => (
<StationNumbersBox
key={stn}
stn={stn}
se={se}
fontScale={fontScale}
textColor={textOnPrimary}
/>
))}
</View>
<View
style={{
position: "relative",
flexDirection: "column",
width: 0,
height: "100%",
alignItems: "flex-end",
}}
>
<View style={{ flex: 1 }} />
<View style={{ position: "relative", height: 25 }}>
<Text
style={{
fontSize: fontScale(20),
position: "absolute",
textAlignVertical: "center",
left: -15,
top: "auto",
}}
>
{points && "🚊"}
</Text>
{points && (
<View
style={{
position: "relative",
width: 0,
alignSelf: "stretch",
alignItems: "flex-end",
justifyContent: "center",
}}
>
<View style={{ position: "relative", height: 25 }}>
<Text
style={{
fontSize: fontScale(20),
position: "absolute",
textAlignVertical: "center",
left: -15,
top: "auto",
}}
>
🚊
</Text>
</View>
</View>
<View style={{ flex: 1 }} />
</View>
)}
<View
style={{
padding: 8,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
borderBottomWidth: 1,
borderBottomColor: themeColors.borderLight,
borderBottomColor: borderColor,
flex: 1,
}}
>
<View style={{ flexDirection: "column" }}>
<View style={{ flex: 1 }} />
<Text
style={{
fontSize: fontScale(20),
@@ -278,64 +295,55 @@ export const EachStopList: FC<props> = ({
{platformDisplay}
</Text>
)}
</Text>
<View style={{ flex: 1 }} />
</View>
<View style={{ flex: 1 }} />
<View style={{ flexDirection: "column", alignItems: "flex-end" }}>
{beforeSameStationData && (
<TimeText
isDouble={!!beforeSameStationData || !!afterSameStationData}
isBefore={true}
currentTrainData={currentTrainData}
se={beforeSameStationData[1]}
time={beforeSameStationData[2]}
key={"before" + beforeSameStationData[2]}
textColor={beforeTimeTextColor}
normalTextColor={beforeNormalTimeTextColor}
/>
)}
<TimeText
isDouble={!!beforeSameStationData || !!afterSameStationData}
currentTrainData={currentTrainData}
se={se}
time={time}
key={se + time}
textColor={colors.timeText}
normalTextColor={normalColors.timeText}
/>
</View>
</Text>
{hasDoubleTime ? (
<View style={{ flexDirection: "column", alignItems: "flex-end" }}>
{beforeTimeText}
{mainTimeText}
</View>
) : (
mainTimeText
)}
</View>
</View>
</TouchableWithoutFeedback>
);
};
const TimeText: FC<{
export const EachStopList = React.memo(EachStopListComponent);
const TimeTextComponent: FC<{
isBefore?: boolean;
isDouble: boolean;
currentTrainData: trainDataType;
delay: TrainDelay;
se: string;
time: string;
textColor: string;
normalTextColor: string;
}> = ({
clockBase: Dayjs;
} & ResponsiveScale> = ({
isDouble,
currentTrainData,
delay,
se,
time,
isBefore = false,
textColor,
normalTextColor,
clockBase,
fontScale,
moderateScale,
}) => {
const [seString, seType] = parseSeString(se);
const { fontScale, moderateScale } = useResponsive();
return (
<View style={{ flexDirection: "row", alignItems: "center", opacity: isBefore ? 0.5 : 1 }}>
{!!currentTrainData?.delay &&
currentTrainData?.delay != "入線" &&
currentTrainData?.delay != 0 && (
<View
style={{
flexDirection: "row",
alignItems: "center",
opacity: isBefore ? 0.5 : 1,
}}
>
{time !== "" && !!delay && delay != "入線" && delay != 0 && (
<Text
style={{
fontSize: fontScale(isBefore ? 14 : 15),
@@ -354,12 +362,15 @@ const TimeText: FC<{
)}
<StationTimeBox
isBefore={isBefore}
delay={currentTrainData?.delay}
delay={delay}
textColor={textColor}
seType={seType}
se={se}
time={time}
isDouble={isDouble}
clockBase={clockBase}
fontScale={fontScale}
moderateScale={moderateScale}
/>
<Text
style={{
@@ -375,10 +386,15 @@ const TimeText: FC<{
</View>
);
};
const StationNumbersBox: FC<{ stn: string; se: seTypes }> = (props) => {
const { stn, se } = props;
const { fixed } = useThemeColors();
const { fontScale } = useResponsive();
const TimeText = React.memo(TimeTextComponent);
const StationNumbersBoxComponent: FC<{
stn: string;
se: SeTypes;
textColor: string;
fontScale: ResponsiveScale["fontScale"];
}> = (props) => {
const { stn, se, textColor, fontScale } = props;
const lineColor = lineColorList[stn.charAt(0)];
const hasThrew =
se == "通過" ||
@@ -391,44 +407,74 @@ const StationNumbersBox: FC<{ stn: string; se: seTypes }> = (props) => {
? "80"
: "";
const backgroundColor = `${lineColor}${hasThrew}`;
return (
<View style={{ backgroundColor, flex: 1 }} key={stn}>
<View style={{ flex: 1 }} />
<Text
const stationNumberLabel = `${stn.charAt(0)}\n${stn.slice(1)}`;
const stationNumberTextStyle = {
color: textColor,
textAlign: "center" as const,
fontSize: fontScale(10),
fontWeight: "bold" as const,
};
if (Platform.OS === "ios") {
return (
<View
style={{
color: fixed.textOnPrimary,
textAlign: "center",
fontSize: fontScale(10),
fontWeight: "bold",
backgroundColor,
flex: 1,
justifyContent: "center",
}}
>
{stn.charAt(0)}
{"\n"}
{stn.slice(1)}
</Text>
<View style={{ flex: 1 }} />
</View>
<Text style={stationNumberTextStyle}>{stationNumberLabel}</Text>
</View>
);
}
return (
<Text
style={{
...stationNumberTextStyle,
backgroundColor,
flex: 1,
textAlignVertical: "center",
}}
>
{stationNumberLabel}
</Text>
);
};
type StationTimeBoxType = {
isBefore?: boolean;
delay: "入線" | number | undefined;
delay: TrainDelay;
textColor: string;
seType: seTypes;
seType: SeTypes;
se: string;
time: string;
isDouble: boolean;
};
clockBase: Dayjs;
} & ResponsiveScale;
const StationTimeBox: FC<StationTimeBoxType> = (props) => {
const { delay, textColor, seType, se, time, isDouble, isBefore } = props;
const { fontScale, moderateScale } = useResponsive();
const dates = setClockTime(
dayjs(),
const StationNumbersBox = React.memo(StationNumbersBoxComponent);
const StationTimeBoxComponent: FC<StationTimeBoxType> = (props) => {
const {
delay,
textColor,
seType,
se,
time,
delay == "入線" || delay == undefined ? 0 : delay
);
isDouble,
isBefore,
clockBase,
fontScale,
moderateScale,
} = props;
const timeData = setClockTime(
clockBase,
time,
delay == "入線" || delay == undefined ? 0 : delay,
)?.format("HH:mm") ?? null;
return (
<Text
@@ -440,7 +486,9 @@ const StationTimeBox: FC<StationTimeBoxType> = (props) => {
fontStyle: seType == "community" ? "italic" : "normal",
}}
>
{se.includes("通") && time == "" ? "レ" : dates?.format("HH:mm") ?? ""}
{se.includes("通") && time == "" ? "レ" : (timeData ?? "")}
</Text>
);
};
const StationTimeBox = React.memo(StationTimeBoxComponent);
@@ -1,23 +1,14 @@
import React, { FC } from "react";
import { View } from "react-native";
import { TrainDataView } from "./TrainDataView";
import { trainDataType } from "@/lib/trainPositionTextArray";
import type { NavigateFunction } from "@/types";
type props = {
currentTrainData: trainDataType;
currentTrainDataList?: trainDataType[];
currentPosition: string[] | undefined;
currentPositionList?: string[][];
nearTrainIDList: string[];
openTrainInfo: (f: string) => void;
navigate: NavigateFunction;
}
export const LongHeader:FC<props> = ({
import type { TrainDataViewHeaderProps } from "./TrainDataView";
type props = TrainDataViewHeaderProps;
export const LongHeader: FC<props> = ({
currentTrainData,
currentTrainDataList,
nearTrainIDList,
currentPosition,
currentPositionList,
nearTrainIDList,
openTrainInfo,
navigate,
}) => {
@@ -31,14 +22,13 @@ export const LongHeader:FC<props> = ({
}}
>
<TrainDataView
nearTrainIDList={nearTrainIDList}
currentTrainData={currentTrainData}
currentTrainDataList={currentTrainDataList}
currentPosition={currentPosition}
currentPositionList={currentPositionList}
nearTrainIDList={nearTrainIDList}
openTrainInfo={openTrainInfo}
navigate={navigate}
key={"LongHeader"}
/>
</View>
);
@@ -8,131 +8,194 @@ type stateBox = {
currentTrainData: trainDataType | undefined;
title: string;
style?: ViewStyle;
mode?: number;
mode?: 0 | 1 | 2;
};
export const PositionBox: FC<stateBox> = (props) => {
const {
currentTrainData,
title,
style,
mode,
} = props;
const { colors } = useThemeColors();
const { currentTrainData, title, style, mode } = props;
const { fontScale } = useResponsive();
const {officialPosition} = currentTrainData || {};
const mainFontSize = mode == 2 ? fontScale(16) : fontScale(23);
const { colors } = useThemeColors();
const { officialPosition } = currentTrainData || {};
const isStationPosition = officialPosition?.positionType === "station";
const platformNumber = isStationPosition
? officialPosition?.platform
: undefined;
const lineNumber = isStationPosition ? officialPosition?.track : undefined;
const platformDescription = officialPosition?.note || ``;
// Notes describe the current position even when it is a prediction or a
// section between stations. Platform/track remain station-only fields, but
// the note itself must not be hidden by position_type.
const platformDescription = officialPosition?.note;
let firstText = "";
let secondText = "";
let marginText = "";
let externalText = "";
const { isBetween, Pos: PosData } = trainPosition(currentTrainData);
if (isBetween === true) {
const { from, to } = PosData;
firstText = from;
secondText = to;
marginText = mode == 2 ? "→" : "↓";
} else {
const { Pos } = PosData;
if (Pos !== "") {
firstText = Pos;
if (platformNumber) {
secondText = `${platformNumber}番乗り場`;
if (lineNumber) {
externalText = `${lineNumber}番線`;
}
} else if (lineNumber) {
secondText = `${lineNumber}番線`;
}
}
}
return (
<View style={{ ...(mode == 2 ? boxStyle2 : boxStyle), backgroundColor: colors.surface, ...style }}>
<Text
numberOfLines={1}
ellipsizeMode="tail"
style={{ flexShrink: 1, fontSize: fontScale(12), color: colors.textAccent }}
>
{title}
</Text>
<View style={{ flex: 1 }} />
const firstText = from;
const secondText = to;
const marginText = mode == 2 ? "→" : "↓";
return (
<View
style={{
minWidth: 0,
flexShrink: 1,
flexDirection: mode == 2 ? "row" : "column",
...(mode == 2 ? boxStyle2 : boxStyle),
backgroundColor: colors.surface,
...style,
}}
>
{firstText && (
<Text
numberOfLines={1}
ellipsizeMode="tail"
style={[
{ flexShrink: 1, minWidth: 0 },
mode == 2
? { fontSize: fontScale(16), textAlign: "right" as const }
: isBetween
? { fontSize: fontScale(23), textAlign: "right" as const }
: { fontSize: fontScale(23), textAlign: "right" as const },
{ color: colors.textAccent },
]}
>
{firstText}
</Text>
)}
{marginText && (
<Text style={{ color: colors.textAccent, textAlign: "right" }}>
{marginText}
</Text>
)}
{secondText && (
<Text
numberOfLines={1}
ellipsizeMode="tail"
style={[
{ flexShrink: 1, minWidth: 0 },
mode == 2
? { fontSize: fontScale(16), textAlign: "right" as const }
: isBetween
? { fontSize: fontScale(23), textAlign: "right" as const }
: { fontSize: fontScale(14), textAlign: "right" as const },
{ color: colors.textAccent },
]}
>
{secondText}
</Text>
)}
</View>
{(platformDescription || externalText) && (
<View style={{ flexDirection: mode == 2 ? "row" : "column" }}>
<Text
numberOfLines={1}
ellipsizeMode="tail"
style={{
flexShrink: 1,
fontSize: fontScale(12),
color: colors.textAccent,
}}
>
{title}
</Text>
<View style={{ flex: 1 }} />
<View
style={{
minWidth: 0,
flexShrink: 1,
flexDirection: mode == 2 ? "row" : "column",
}}
>
<Text
numberOfLines={1}
ellipsizeMode="tail"
style={{
flexShrink: 1,
minWidth: 0,
...(mode == 2
? { fontSize: fontScale(16), textAlign: "right" as const }
: { fontSize: fontScale(23), textAlign: "right" as const }),
fontSize: fontScale(10),
textAlign: "right",
fontSize: mainFontSize,
color: colors.textAccent,
}}
>
{" " + externalText}
{" " + platformDescription}
{firstText}
</Text>
<Text style={{ color: colors.textAccent, textAlign: "right" }}>
{marginText}
</Text>
<Text
numberOfLines={1}
ellipsizeMode="tail"
style={{
flexShrink: 1,
minWidth: 0,
textAlign: "right",
fontSize: mainFontSize,
color: colors.textAccent,
}}
>
{secondText}
</Text>
</View>
)}
</View>
);
{platformDescription && (
<View style={{ flexDirection: mode == 2 ? "row" : "column" }}>
<Text
numberOfLines={1}
ellipsizeMode="tail"
style={{
flexShrink: 1,
minWidth: 0,
textAlign: "right",
fontSize: fontScale(10),
color: colors.textAccent,
}}
>
{" " + platformDescription}
</Text>
</View>
)}
</View>
);
} else {
const { Pos } = PosData;
const subFontSize = mode == 2 ? fontScale(16) : fontScale(14);
const firstText = Pos !== "" ? Pos : "";
let secondText = "";
let externalText = "";
if (platformNumber) {
secondText = `${platformNumber}番乗り場`;
externalText = lineNumber ? `${lineNumber}番線` : "";
} else if (lineNumber) {
secondText = `${lineNumber}番線`;
}
return (
<View
style={{
...(mode == 2 ? boxStyle2 : boxStyle),
backgroundColor: colors.surface,
...style,
}}
>
<Text
numberOfLines={1}
ellipsizeMode="tail"
style={{
flexShrink: 1,
fontSize: fontScale(12),
color: colors.textAccent,
}}
>
{title}
</Text>
<View style={{ flex: 1 }} />
<View
style={{
minWidth: 0,
flexShrink: 1,
flexDirection: mode == 2 ? "row" : "column",
}}
>
<Text
numberOfLines={1}
ellipsizeMode="tail"
style={{
flexShrink: 1,
minWidth: 0,
fontSize: mainFontSize,
textAlign: "right",
color: colors.textAccent,
}}
>
{firstText}
</Text>
<Text
numberOfLines={1}
ellipsizeMode="tail"
style={{
flexShrink: 1,
minWidth: 0,
fontSize: subFontSize,
textAlign: "right",
color: colors.textAccent,
}}
>
{secondText}
</Text>
</View>
{(platformDescription || externalText) && (
<View style={{ flexDirection: mode == 2 ? "row" : "column" }}>
<Text
numberOfLines={1}
ellipsizeMode="tail"
style={{
flexShrink: 1,
minWidth: 0,
textAlign: "right",
fontSize: fontScale(10),
color: colors.textAccent,
}}
>
{" " + externalText}
{" " + platformDescription}
</Text>
</View>
)}
</View>
);
}
};
const boxStyle: ViewStyle = {
flex: 1,
@@ -1,12 +1,27 @@
import React from "react";
import { View, Text, TouchableOpacity } from "react-native";
import type { trainDataType } from "@/lib/trainPositionTextArray";
import { useThemeColors } from "@/lib/theme";
import { useResponsive } from "@/lib/responsive";
export const ScrollStickyContent = (props) => {
const { currentTrainData, showThrew, setShowThrew, haveThrough } = props;
type Props = {
trainDelay: trainDataType["delay"];
showThrew: boolean;
setShowThrew: React.Dispatch<React.SetStateAction<boolean>>;
haveThrough: boolean;
};
export const ScrollStickyContent = ({
trainDelay,
showThrew,
setShowThrew,
haveThrough,
}: Props) => {
const { colors } = useThemeColors();
const { fontScale } = useResponsive();
const hasNumericDelay =
typeof trainDelay === "number" && !Number.isNaN(trainDelay);
const hasDelay = hasNumericDelay && trainDelay !== 0;
return (
<View
style={{
@@ -27,7 +42,7 @@ export const ScrollStickyContent = (props) => {
<Text style={{ fontSize: fontScale(20), color: colors.text }}>停車駅</Text>
<View style={{ flex: 1 }} />
<View style={{ flexDirection: "row" }}>
{!isNaN(currentTrainData?.delay) && currentTrainData?.delay != 0 && (
{hasDelay && (
<Text
style={{
fontSize: fontScale(15),
@@ -44,11 +59,7 @@ export const ScrollStickyContent = (props) => {
<Text
style={{
fontSize: fontScale(20),
color: isNaN(currentTrainData?.delay)
? colors.text
: currentTrainData?.delay == 0
? colors.text
: colors.textError,
color: hasDelay ? colors.textError : colors.text,
width: 60,
}}
>
@@ -1,23 +1,14 @@
import React, { FC } from "react";
import { View } from "react-native";
import { TrainDataView } from "./TrainDataView";
import { trainDataType } from "@/lib/trainPositionTextArray";
import type { NavigateFunction } from "@/types";
type props = {
currentTrainData: trainDataType;
currentTrainDataList?: trainDataType[];
currentPosition: string[] | undefined;
currentPositionList?: string[][];
nearTrainIDList: string[];
openTrainInfo: (f: string) => void;
navigate: NavigateFunction;
}
export const ShortHeader:FC<props> = ({
import type { TrainDataViewHeaderProps } from "./TrainDataView";
type props = TrainDataViewHeaderProps;
export const ShortHeader: FC<props> = ({
currentTrainData,
currentTrainDataList,
nearTrainIDList,
currentPosition,
currentPositionList,
nearTrainIDList,
openTrainInfo,
navigate,
}) => {
@@ -31,15 +22,14 @@ export const ShortHeader:FC<props> = ({
}}
>
<TrainDataView
mode={2}
nearTrainIDList={nearTrainIDList}
currentTrainData={currentTrainData}
currentTrainDataList={currentTrainDataList}
currentPosition={currentPosition}
currentPositionList={currentPositionList}
nearTrainIDList={nearTrainIDList}
mode={2}
openTrainInfo={openTrainInfo}
navigate={navigate}
key={"ShortHeader"}
/>
</View>
);
@@ -1,5 +1,5 @@
import React, { CSSProperties, FC } from "react";
import { View, Text, StyleProp, ViewStyle } from "react-native";
import React, { FC } from "react";
import { View, Text, ViewStyle } from "react-native";
import { useThemeColors } from "@/lib/theme";
import { useResponsive } from "@/lib/responsive";
@@ -7,7 +7,7 @@ type stateBox = {
text: string;
title: string;
style?: ViewStyle;
mode?: number;
mode?: 0 | 1 | 2;
endText?: string;
};
export const StateBox: FC<stateBox> = (props) => {
@@ -1,63 +1,166 @@
import React, { useState, useEffect, FC } from "react";
import React, { useState, FC } from "react";
import {
ScrollView,
Text,
View,
TouchableOpacity,
ViewStyle,
useWindowDimensions,
} from "react-native";
import { ScrollView } from "react-native-actions-sheet";
import { StateBox } from "./StateBox";
import { PositionBox } from "./PositionBox";
import { useCurrentTrainView } from "../../../stateBox/useCurrentTrain";
import { useCurrentTrainView } from "@/stateBox/useCurrentTrain";
import { SheetManager } from "react-native-actions-sheet";
import { trainDataType } from "@/lib/trainPositionTextArray";
import { useCustomTrainData } from "@/stateBox/useAllTrainDiagram";
import { customTrainDataDetector } from "@/components/custom-train-data";
import type { trainDataType } from "@/lib/trainPositionTextArray";
import type { NavigateFunction } from "@/types";
import { stackAwareNavigate } from "@/lib/rootNavigation";
import { OfficialPositionEditButton } from "@/components/OfficialPositionEditButton";
import { lineDisplayName } from "@/lib/lineDisplayName";
type props = {
export type TrainDataViewData = {
currentTrainData: trainDataType | undefined;
currentTrainDataList?: trainDataType[];
currentPosition: string[] | undefined;
currentPositionList?: string[][];
showLineLabel?: boolean;
currentTrainDataList: trainDataType[];
currentPosition: string[];
currentPositionList: string[][];
};
export type TrainDataViewMode = 0 | 1 | 2;
export type TrainDataViewControls = {
nearTrainIDList: string[];
openTrainInfo: (f: string) => void;
mode?: 0 | 1 | 2; //0:通常,1:コンパクト,2:横並び
navigate: NavigateFunction;
};
const TrainDataViewRow: FC<props> = ({
export type TrainDataViewHeaderProps = TrainDataViewData &
TrainDataViewControls;
type TrainDataViewProps = TrainDataViewHeaderProps & {
mode?: TrainDataViewMode; // 0:通常, 1:コンパクト, 2:横並び
};
export const TrainDataView: FC<TrainDataViewProps> = ({
currentTrainData,
currentTrainDataList,
currentPosition,
showLineLabel,
currentPositionList,
...controls
}) => {
const { width: windowWidth } = useWindowDimensions();
const rows = currentTrainDataList?.length
? currentTrainDataList
: [currentTrainData];
const positions = currentPositionList?.length
? currentPositionList
: [currentPosition ?? []];
const [activeIndex, setActiveIndex] = useState(0);
const [pageWidth, setPageWidth] = useState(windowWidth);
const hasMultipleRows = rows.length > 1;
const visibleActiveIndex = Math.min(
activeIndex,
Math.max(rows.length - 1, 0),
);
return (
<View
onLayout={(event) => {
const nextWidth = event.nativeEvent.layout.width;
setPageWidth((previous) =>
previous === nextWidth ? previous : nextWidth,
);
}}
style={{ flex: 1, width: "100%" }}
>
<ScrollView
horizontal
pagingEnabled={hasMultipleRows}
scrollEnabled={hasMultipleRows}
nestedScrollEnabled
directionalLockEnabled
showsHorizontalScrollIndicator={false}
style={{ flex: 1, width: "100%" }}
contentContainerStyle={{ flexGrow: 1, alignItems: "stretch" }}
onMomentumScrollEnd={(event) => {
const width = event.nativeEvent.layoutMeasurement.width || pageWidth;
if (!width) return;
setActiveIndex(
Math.max(
0,
Math.min(
rows.length - 1,
Math.round(event.nativeEvent.contentOffset.x / width),
),
),
);
}}
>
{rows.map((d, index) => (
<RenderRow
{...controls}
train={d}
position={positions[index] ?? []}
pageWidth={pageWidth}
showLineLabel={rows.length > 1}
index={index}
key={index}
/>
))}
</ScrollView>
{hasMultipleRows && (
<Text style={{ alignSelf: "center", fontSize: 11 }}>
{visibleActiveIndex + 1} / {rows.length}
</Text>
)}
</View>
);
};
type RenderRowProps = TrainDataViewRowProps & {
pageWidth: number;
index: number;
};
const RenderRow = (props: RenderRowProps) => {
const { pageWidth, index, ...rowProps } = props;
return (
<View
style={{
width: pageWidth || "100%",
flexGrow: 0,
flexShrink: 0,
alignSelf: "stretch",
}}
>
<TrainDataViewRow {...rowProps} />
</View>
);
};
type TrainDataViewRowProps = TrainDataViewControls & {
train: trainDataType | undefined;
position: string[];
showLineLabel: boolean;
mode?: TrainDataViewMode;
};
const TrainDataViewRow: FC<TrainDataViewRowProps> = ({
train,
nearTrainIDList,
position,
showLineLabel,
openTrainInfo,
mode = 0,
navigate,
}) => {
const { width} = useWindowDimensions();
const trainNum = train?.num ?? "";
const currentTrainData = train;
const currentPosition = position;
const { width } = useWindowDimensions();
const { setInjectData } = useCurrentTrainView();
const { allCustomTrainData } = useCustomTrainData();
const onLine = !!currentPosition?.toString().length;
const lineLabel = lineDisplayName(currentTrainData?.Line);
const [trainNumber, setTrainNumber] = useState(currentTrainData?.num);
useEffect(() => {
const { train_number_override } = customTrainDataDetector(
currentTrainData?.num,
allCustomTrainData,
);
if (train_number_override) {
setTrainNumber(train_number_override);
} else {
setTrainNumber(currentTrainData?.num);
}
}, [currentTrainData?.num, allCustomTrainData]);
const columnStyle = {
width: width / 2,
display: "flex",
@@ -91,7 +194,7 @@ const TrainDataViewRow: FC<props> = ({
display: "flex",
}}
>
<View style={{ flexDirection: "row",flex:1,...columnStyle }}>
<View style={{ flexDirection: "row", flex: 1, ...columnStyle }}>
<TouchableOpacity
style={{ flex: 1, minWidth: 0, flexDirection: "row" }}
//disabled={!onLine}
@@ -99,7 +202,7 @@ const TrainDataViewRow: FC<props> = ({
if (!currentTrainData) return;
setInjectData({
type: "train",
value: currentTrainData?.num,
value: trainNum,
fixed: true,
});
stackAwareNavigate("positions");
@@ -139,14 +242,11 @@ const TrainDataViewRow: FC<props> = ({
</View>
<View
style={
{
flexDirection: mode == 2 ? "row" : "column",
flex: 1,
...columnStyle
}
}
style={{
flexDirection: mode == 2 ? "row" : "column",
flex: 1,
...columnStyle,
}}
>
<View style={rightItemStyle}>
<StateBox
@@ -174,7 +274,7 @@ const TrainDataViewRow: FC<props> = ({
}}
>
{nearTrainIDList.length == 0 ? (
<StateBox mode={mode} title="列番" text={trainNumber} />
<StateBox mode={mode} title="列番" text={trainNum} />
) : (
<StateBox
mode={mode}
@@ -192,82 +292,3 @@ const TrainDataViewRow: FC<props> = ({
</View>
);
};
export const TrainDataView: FC<props> = ({
currentTrainData,
currentTrainDataList,
currentPosition,
currentPositionList,
...rest
}) => {
const rows = currentTrainDataList?.length
? currentTrainDataList
: [currentTrainData];
const positions = currentPositionList?.length
? currentPositionList
: [currentPosition ?? []];
const [activeIndex, setActiveIndex] = useState(0);
const [pageWidth, setPageWidth] = useState(0);
useEffect(() => {
setActiveIndex((index) => Math.min(index, Math.max(rows.length - 1, 0)));
}, [rows.length]);
const renderRow = (train: trainDataType | undefined, index: number) => (
<View
key={`${train?.Line ?? "unknown"}-${String(train?.PosNum ?? "")}-${index}`}
style={{
width: pageWidth || "100%",
flexGrow: 0,
flexShrink: 0,
alignSelf: "stretch",
}}
>
<TrainDataViewRow
{...rest}
currentTrainData={train}
currentPosition={positions[index] ?? []}
showLineLabel={rows.length > 1}
/>
</View>
);
const hasMultipleRows = rows.length > 1;
return (
<View
onLayout={(event) => setPageWidth(event.nativeEvent.layout.width)}
style={{ flex: 1, width: "100%" }}
>
<ScrollView
horizontal
pagingEnabled={hasMultipleRows}
scrollEnabled={hasMultipleRows}
nestedScrollEnabled
showsHorizontalScrollIndicator={false}
style={{ flex: 1, width: "100%" }}
contentContainerStyle={{ flexGrow: 1, alignItems: "stretch" }}
onMomentumScrollEnd={(event) => {
const width = event.nativeEvent.layoutMeasurement.width || pageWidth;
if (!width) return;
setActiveIndex(
Math.max(
0,
Math.min(
rows.length - 1,
Math.round(event.nativeEvent.contentOffset.x / width),
),
),
);
}}
>
{rows.map(renderRow)}
</ScrollView>
{hasMultipleRows && (
<Text style={{ alignSelf: "center", fontSize: 11 }}>
{activeIndex + 1} / {rows.length}
</Text>
)}
</View>
);
};
@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import dayjs from "dayjs";
import {
View,
Text,
@@ -6,12 +7,11 @@ import {
StyleSheet,
useWindowDimensions,
BackHandler,
Linking,
} from "react-native";
import { SheetManager } from "react-native-actions-sheet";
import { getTrainType } from "../../lib/getTrainType";
import { getTrainType } from "@/lib/getTrainType";
import { customTrainDataDetector } from "../custom-train-data";
import { EachStopList } from "./EachTrainInfo/EachStopList";
import { EachStopList, type EachStopData } from "./EachTrainInfo/EachStopList";
import { DataConnectedButton } from "./EachTrainInfo/DataConnectedButton";
import { DynamicHeaderScrollView } from "../DynamicHeaderScrollView";
import { LongHeader } from "./EachTrainInfo/LongHeader";
@@ -19,10 +19,13 @@ import { ShortHeader } from "./EachTrainInfo/ShortHeader";
import { ScrollStickyContent } from "./EachTrainInfo/ScrollStickyContent";
import { ShowSpecialTrain } from "./EachTrainInfo/ShowSpecialTrain";
import { HeaderText } from "./EachTrainInfoCore/HeaderText";
import { useStationList } from "../../stateBox/useStationList";
import { useCurrentTrainData } from "../../stateBox/useCurrentTrain";
import { useStationList } from "@/stateBox/useStationList";
import { useCurrentTrainData } from "@/stateBox/useCurrentTrain";
import { useThemeColors } from "@/lib/theme";
import { useCustomTrainData } from "@/stateBox/useAllTrainDiagram";
import {
useCustomTrainData,
useOperationLogs,
} from "@/stateBox/useAllTrainDiagram";
import { useResponsive } from "@/lib/responsive";
// Custom hooks
@@ -34,6 +37,7 @@ import { useTrainPosition } from "./EachTrainInfoCore/hooks/useTrainPosition";
import { useAutoScroll } from "./EachTrainInfoCore/hooks/useAutoScroll";
import { useExtendedStations } from "./EachTrainInfoCore/hooks/useExtendedStations";
import type { EachTrainInfoPayload } from "@/types/actionSheets";
import { openExternalUrl } from "@/lib/openExternalUrl";
type EachTrainInfoCoreProps = {
trainNum: EachTrainInfoPayload["trainNum"];
@@ -43,6 +47,11 @@ type EachTrainInfoCoreProps = {
sheetOpened?: boolean;
};
const parseEachStopData = (data: string): EachStopData => {
const [station = "", se = "", time = "", platformNum] = data.split(",");
return [station, se, time, platformNum];
};
export const EachTrainInfoCore = ({
trainNum,
openStationACFromEachTrainInfo,
@@ -51,21 +60,56 @@ export const EachTrainInfoCore = ({
sheetOpened = false,
}: EachTrainInfoCoreProps) => {
const from = fromValue ?? "";
const { stationList } = useStationList();
const { allCustomTrainData } = useCustomTrainData();
const { colors, fixed } = useThemeColors();
const { verticalScale, moderateScale } = useResponsive();
const { allCustomTrainData, activateCustomTrainData } = useCustomTrainData();
const { activateOperationLogs } = useOperationLogs();
const { colors, fixed, isDark } = useThemeColors();
const { verticalScale, moderateScale, fontScale } = useResponsive();
const { height } = useWindowDimensions();
const { getCurrentStationData } = useCurrentTrainData();
const { stationList } = useStationList();
useEffect(() => {
if (!sheetOpened) return;
activateCustomTrainData();
activateOperationLogs();
}, [
activateCustomTrainData,
activateOperationLogs,
sheetOpened,
]);
const stationNumberMap = useMemo(() => {
const map = new Map<string, string[]>();
for (const lineStations of stationList) {
for (const station of lineStations) {
if (station.StationNumber == null) continue;
const numbers = map.get(station.StationName);
if (numbers) {
numbers.push(station.StationNumber);
} else {
map.set(station.StationName, [station.StationNumber]);
}
}
}
return map;
}, [stationList]);
const scrollRef = useRef<any>(null);
// Custom hooks for data management
const { trainData, setTrainData, trueTrainID } =
useTrainDiagramData(trainNum);
const { trainDataWithThrough, haveThrough } = useThroughStations(trainData);
const {
showHeadStation,
showTailStation,
includedTrainIDs,
extendToHeadStation,
extendToTailStation,
} = useExtendedStations(trainNum, trainData, setTrainData);
const { headStation, tailStation, nearTrainIDList } = useNearbyTrains(
trainNum,
trainData,
includedTrainIDs,
);
const stopStationIDList = useStopStationIDs(trainDataWithThrough);
const {
@@ -77,12 +121,15 @@ export const EachTrainInfoCore = ({
pointsDisplay,
} = useTrainPosition(trainNum, stopStationIDList);
const {
showHeadStation,
showTailStation,
extendToHeadStation,
extendToTailStation,
} = useExtendedStations(trainData, setTrainData);
const parsedTrainData = useMemo<readonly EachStopData[]>(
() =>
trainDataWithThrough.map(parseEachStopData),
[trainDataWithThrough],
);
const clockBase = useMemo(
() => dayjs(),
[trainDataWithThrough, currentTrainData?.delay],
);
// UI state
// 走行中の列車は初期状態から通過駅を表示する(後から showThrew を true に変更すると
@@ -90,15 +137,11 @@ export const EachTrainInfoCore = ({
const [showThrew, setShowThrew] = useState(
() => !!getCurrentStationData(trainNum),
);
const [isJumped, setIsJumped] = useState(false);
// Auto scroll to current position
useAutoScroll(
points,
trainDataWithThrough,
scrollRef,
isJumped,
setIsJumped,
sheetOpened,
);
@@ -132,6 +175,133 @@ export const EachTrainInfoCore = ({
}, 200);
});
};
const renderTrainDataItem = (item: string, index: number) => {
const stopType = parsedTrainData[index]?.[1] ?? "";
if (stopType === "増" || stopType === "解") {
return (
<DataConnectedButton
i={item}
openTrainInfo={openTrainInfo}
/>
);
}
if (stopType.includes(".")) return null;
return (
<EachStopList
stationNumberMap={stationNumberMap}
parsedTrainData={parsedTrainData}
clockBase={clockBase}
borderColor={colors.borderLight}
textOnPrimary={fixed.textOnPrimary}
isDark={isDark}
trainDelay={currentTrainData?.delay}
fontScale={fontScale}
moderateScale={moderateScale}
index={index}
points={pointsDisplay?.[index] || false}
openStationACFromEachTrainInfo={openStationACFromEachTrainInfo}
showThrew={showThrew}
isNotService={customTrainType.data === "notService"}
/>
);
};
const listHeader = (
<>
{customTrainType.data === "notService" && (
<Text
style={{
backgroundColor: colors.surface,
color: colors.text,
fontWeight: "bold",
}}
>
この列車には乗車できません。
</Text>
)}
{headStation.length > 0 &&
headStation.map(
(item) =>
!showHeadStation.includes(item.id) && (
<TouchableOpacity
onPress={() =>
extendToHeadStation(item.station, item.dia, item.id)
}
style={[
styles.extendStationButton,
{ borderColor: colors.textAccent },
]}
key={item.id + "-head"}
>
<Text
style={[styles.extendStationText, { color: colors.text }]}
>
「本当の始発駅」を表示
</Text>
</TouchableOpacity>
),
)}
<ShowSpecialTrain
isTrainDataNothing={trainData.length === 0}
setTrainData={setTrainData}
trueTrainID={trueTrainID}
/>
{!trainData.length && (
<TouchableOpacity
onPress={() =>
openExternalUrl("https://twitter.com/search?q=" + trainNum)
}
style={[
styles.twitterSearchButton,
{
borderColor: colors.textAccent,
backgroundColor: colors.backgroundOverlay,
},
]}
>
<Text style={[styles.extendStationText, { color: colors.text }]}>
Twitterで検索
</Text>
</TouchableOpacity>
)}
</>
);
const listFooter = (
<>
<Text style={[styles.customDataNote, { color: colors.textSecondary }]}>
時刻が斜体,青色になっている時刻はコミュニティで追加されている独自データです。
</Text>
{tailStation.length > 0 &&
tailStation.map(
({ station, dia, id }) =>
!showTailStation.includes(id) && (
<TouchableOpacity
onPress={() => extendToTailStation(station, dia, id)}
style={[
styles.extendStationButton,
{ borderColor: colors.textAccent },
]}
key={id + "-tail"}
>
<Text
style={[styles.extendStationText, { color: colors.text }]}
>
「本当の終着駅」を表示
</Text>
</TouchableOpacity>
),
)}
<View
style={[
styles.bottomSpacer,
{ borderBottomColor: colors.borderLight },
]}
/>
</>
);
return (
<View
style={{
@@ -159,14 +329,11 @@ export const EachTrainInfoCore = ({
trainData={trainData}
showHeadStation={showHeadStation}
showTailStation={showTailStation}
headStation={headStation}
tailStation={tailStation}
navigate={navigate}
from={from}
fontLoaded={true}
scrollRef={scrollRef}
/>
<DynamicHeaderScrollView
from={from}
styles={styles as any}
@@ -183,145 +350,47 @@ export const EachTrainInfoCore = ({
shortHeader={
<ShortHeader
{...{
openTrainInfo,
navigate,
nearTrainIDList,
currentTrainData,
currentTrainDataList,
currentPosition,
currentPositionList,
nearTrainIDList,
openTrainInfo,
navigate,
}}
/>
}
longHeader={
<LongHeader
{...{
openTrainInfo,
navigate,
currentTrainData,
nearTrainIDList,
currentTrainDataList,
currentPosition,
currentPositionList,
nearTrainIDList,
openTrainInfo,
navigate,
}}
/>
}
topStickyContent={
<ScrollStickyContent
{...{ currentTrainData, showThrew, setShowThrew, haveThrough }}
{...{
trainDelay: currentTrainData?.delay,
showThrew,
setShowThrew,
haveThrough,
}}
/>
}
>
{customTrainType.data === "notService" && (
<Text
style={{
backgroundColor: colors.surface,
color: colors.text,
fontWeight: "bold",
}}
>
この列車には乗車できません。
</Text>
)}
{headStation.length > 0 &&
headStation.map(
(item, index) =>
!showHeadStation.includes(index) && (
<TouchableOpacity
onPress={() =>
extendToHeadStation(item.station, item.dia, index)
}
style={[
styles.extendStationButton,
{ borderColor: colors.textAccent },
]}
key={`${item.station}-head${index}`}
>
<Text
style={[styles.extendStationText, { color: colors.text }]}
>
「本当の始発駅」を表示
</Text>
</TouchableOpacity>
),
)}
<ShowSpecialTrain
isTrainDataNothing={trainData.length === 0}
setTrainData={setTrainData}
trueTrainID={trueTrainID}
/>
{!trainData.length && (
<TouchableOpacity
onPress={() =>
Linking.openURL(`https://twitter.com/search?q=${trainNum}`)
}
style={[
styles.twitterSearchButton,
{
borderColor: colors.textAccent,
backgroundColor: colors.backgroundOverlay,
},
]}
>
<Text style={[styles.extendStationText, { color: colors.text }]}>
Twitterで検索
</Text>
</TouchableOpacity>
)}
{trainDataWithThrough.map((item, index, array) =>
item.split(",")[1] === "増" || item.split(",")[1] === "解" ? (
<DataConnectedButton
i={item}
key={`${item}-data`}
openTrainInfo={openTrainInfo}
/>
) : item.split(",")[1].includes(".") ? (
<React.Fragment key={`${item}-skip`} />
) : (
<EachStopList
i={item}
index={index}
stationList={stationList as any}
points={pointsDisplay?.[index] || false}
currentTrainData={currentTrainData as any}
openStationACFromEachTrainInfo={openStationACFromEachTrainInfo}
showThrew={showThrew}
array={array}
isNotService={customTrainType.data === "notService"}
key={`${item}-stop`}
/>
),
)}
<Text style={[styles.customDataNote, { color: colors.textSecondary }]}>
時刻が斜体,青色になっている時刻はコミュニティで追加されている独自データです。
</Text>
{tailStation.length > 0 &&
tailStation.map(
({ station, dia }, index) =>
!showTailStation.includes(index) && (
<TouchableOpacity
onPress={() => extendToTailStation(station, dia, index)}
style={[
styles.extendStationButton,
{ borderColor: colors.textAccent },
]}
key={`${station}-tail${index}`}
>
<Text
style={[styles.extendStationText, { color: colors.text }]}
>
「本当の終着駅」を表示
</Text>
</TouchableOpacity>
),
)}
<View
style={[
styles.bottomSpacer,
{ borderBottomColor: colors.borderLight },
]}
/>
{listHeader}
{trainDataWithThrough.map((item, index) => (
<React.Fragment key={`${trainNum}-${index}`}>
{renderTrainDataItem(item, index)}
</React.Fragment>
))}
{listFooter}
</DynamicHeaderScrollView>
</View>
);
@@ -1,7 +1,7 @@
import React, { FC, useMemo } from "react";
import { Text, View, TextStyle, TouchableOpacity } from "react-native";
import { SheetManager } from "react-native-actions-sheet";
import { migrateTrainName } from "../../../lib/eachTrainInfoCoreLib/migrateTrainName";
import { migrateTrainName } from "@/lib/eachTrainInfoCoreLib/migrateTrainName";
import { TrainIconStatus } from "./trainIconStatus";
import { OneManText } from "./HeaderTextParts/OneManText";
import { customTrainDataDetector } from "@/components/custom-train-data";
@@ -26,10 +26,8 @@ import { normalizeIconDisplayMode } from "@/lib/iconDisplayMode";
type Props = {
trainNum: string;
trainData: string[];
showHeadStation: number[];
showTailStation: number[];
headStation: { id: string }[];
tailStation: { id: string }[];
showHeadStation: string[];
showTailStation: string[];
navigate: NavigateFunction;
from: string;
fontLoaded: boolean;
@@ -46,8 +44,6 @@ export const HeaderText: FC<Props> = ({
trainData,
showHeadStation,
showTailStation,
headStation,
tailStation,
navigate,
from,
scrollRef,
@@ -309,9 +305,9 @@ export const HeaderText: FC<Props> = ({
</TouchableOpacity>
<View style={{ flex: 1 }} />
<Text style={{ ...textConfig, fontSize: fontScale(17), color: fixed.textOnPrimary }}>
{showHeadStation.map((d) => `${headStation[d].id} + `)}
{showHeadStation.map((trainID) => `${trainID} + `)}
{trainNum}
{showTailStation.map((d) => ` + ${tailStation[d].id}`)}
{showTailStation.map((trainID) => ` + ${trainID}`)}
</Text>
<MaterialCommunityIcons
name="database"
@@ -2,6 +2,7 @@ import React, { ComponentProps, FC } from "react";
import { Image, TouchableOpacity, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import type { TrainIconEntry } from "@/lib/trainIconEntries";
import { normalizeVehicleInfoUrl } from "@/lib/vehicleInfoUrl";
type StackVariant = "header" | "list";
type StatusIcon = ComponentProps<typeof Ionicons>;
@@ -87,7 +88,7 @@ export const TrainIconStack: FC<Props> = ({
<TouchableOpacity
key={`${trainIcon}-${index}`}
onPress={() => onPressEntry(entry, index)}
disabled={!entry.vehicle_info_url}
disabled={!normalizeVehicleInfoUrl(entry.vehicle_info_url)}
>
{content}
</TouchableOpacity>
@@ -1,33 +1,46 @@
import { useEffect, MutableRefObject } from 'react';
import { useEffect, MutableRefObject, useRef } from 'react';
export const useAutoScroll = (
points: boolean[] | undefined,
trainDataWithThrough: string[],
scrollRef: MutableRefObject<any>,
isJumped: boolean,
setIsJumped: (value: boolean) => void,
sheetOpened: boolean = false
) => {
const jumpedRef = useRef(false);
const trainDataRef = useRef(trainDataWithThrough);
useEffect(() => {
if (trainDataRef.current !== trainDataWithThrough) {
trainDataRef.current = trainDataWithThrough;
jumpedRef.current = false;
}
// ActionSheetのスプリングアニメーション完了後まで待機
if (!sheetOpened || isJumped || !points?.length || !scrollRef) return;
if (!sheetOpened || jumpedRef.current || !points?.length || !scrollRef) return;
const currentPositionIndex = points.findIndex((d) => d === true);
if (currentPositionIndex === -1) return;
// 5駅以内の場合はスクロールしない
if (currentPositionIndex < 5) {
setIsJumped(true);
jumpedRef.current = true;
return;
}
const scrollPosition = currentPositionIndex * 44 - 50;
const timer = setTimeout(() => {
scrollRef.current?.scrollTo({ y: scrollPosition, animated: true });
setIsJumped(true);
if (typeof scrollRef.current?.scrollToOffset === 'function') {
scrollRef.current.scrollToOffset({
offset: scrollPosition,
animated: true,
});
} else {
scrollRef.current?.scrollTo({ y: scrollPosition, animated: true });
}
jumpedRef.current = true;
}, 100);
return () => clearTimeout(timer);
}, [sheetOpened, points, trainDataWithThrough, scrollRef, isJumped, setIsJumped]);
}, [sheetOpened, points, trainDataWithThrough, scrollRef]);
};
@@ -1,29 +1,85 @@
import { useState } from 'react';
import { openBackTrainInfo } from '@/lib/eachTrainInfoCoreLib/openBackTrainInfo';
export const useExtendedStations = (trainData, setTrainData) => {
const [showHeadStation, setShowHeadStation] = useState([]);
const [showTailStation, setShowTailStation] = useState([]);
type ExtendedState = {
trainNum: string;
headTrainIDs: string[];
tailTrainIDs: string[];
includedTrainIDs: string[];
};
const extendToHeadStation = (station, dia, index) => {
const initialExtendedState = (trainNum: string): ExtendedState => ({
trainNum,
headTrainIDs: [],
tailTrainIDs: [],
includedTrainIDs: trainNum ? [trainNum] : [],
});
export const useExtendedStations = (
trainNum: string,
trainData: string[],
setTrainData: (data: string[]) => void,
) => {
const [extendedState, setExtendedState] = useState(() =>
initialExtendedState(trainNum),
);
const currentState =
extendedState.trainNum === trainNum
? extendedState
: initialExtendedState(trainNum);
const extendToHeadStation = (
station: string,
dia: string[],
trainID: string,
) => {
const array = openBackTrainInfo(station, trainData, dia);
if (!array) return;
setTrainData(array);
setShowHeadStation((prev) => [...prev, index]);
setExtendedState((previous) => {
const state =
previous.trainNum === trainNum
? previous
: initialExtendedState(trainNum);
return {
...state,
headTrainIDs: [trainID, ...state.headTrainIDs],
includedTrainIDs: state.includedTrainIDs.includes(trainID)
? state.includedTrainIDs
: [...state.includedTrainIDs, trainID],
};
});
};
const extendToTailStation = (station, dia, index) => {
const extendToTailStation = (
station: string,
dia: string[],
trainID: string,
) => {
const array = openBackTrainInfo(station, trainData, dia);
if (!array) return;
setTrainData(array);
setShowTailStation((prev) => [...prev, index]);
setExtendedState((previous) => {
const state =
previous.trainNum === trainNum
? previous
: initialExtendedState(trainNum);
return {
...state,
tailTrainIDs: [...state.tailTrainIDs, trainID],
includedTrainIDs: state.includedTrainIDs.includes(trainID)
? state.includedTrainIDs
: [...state.includedTrainIDs, trainID],
};
});
};
return {
showHeadStation,
showTailStation,
showHeadStation: currentState.headTrainIDs,
showTailStation: currentState.tailTrainIDs,
includedTrainIDs: currentState.includedTrainIDs,
extendToHeadStation,
extendToTailStation,
};
@@ -1,27 +1,61 @@
import { useState, useEffect } from "react";
import { useMemo } from "react";
import { useBusAndTrainData } from "@/stateBox/useBusAndTrainData";
export const useNearbyTrains = (trainNum, trainData) => {
type InfluencedTrain = { id: string; TrainData: string[] };
type BoundaryTrain = { station: string; dia: string[]; id: string };
export const useNearbyTrains = (
trainNum: string,
trainData: string[],
includedTrainIDs: string[],
) => {
const { getInfluencedTrainData } = useBusAndTrainData();
const [headStation, setHeadStation] = useState([]);
const [tailStation, setTailStation] = useState([]);
const [nearTrainIDList, setNearTrainIDList] = useState([]);
useEffect(() => {
if (!trainNum || !trainData.length) return;
return useMemo(() => {
if (!trainNum || !trainData.length) {
return { headStation: [], tailStation: [], nearTrainIDList: [] };
}
const nearTrainList = getInfluencedTrainData(trainNum);
if (!nearTrainList.length) return;
const nearTrainList = getInfluencedTrainData(trainNum) ?? [];
if (!nearTrainList.length) {
return { headStation: [], tailStation: [], nearTrainIDList: [] };
}
const trainDataArray = nearTrainList.map((d) => d.TrainData);
const trainIDs = nearTrainList.map((d) => d.id);
setNearTrainIDList(trainIDs);
// Opening any train in a multi-train service should allow the whole
// connected chain to be extended. The direct list is still kept for the
// existing header action; boundary candidates come from its transitive
// connections.
const connectedTrainByID = new Map<string, InfluencedTrain>();
const visitedTrainIDs = new Set<string>([trainNum]);
const pendingTrainIDs = [trainNum];
while (pendingTrainIDs.length > 0) {
const currentTrainID = pendingTrainIDs.shift();
if (!currentTrainID) continue;
const connectedTrains = getInfluencedTrainData(currentTrainID) ?? [];
connectedTrains.forEach((connectedTrain) => {
if (!connectedTrain?.id || visitedTrainIDs.has(connectedTrain.id)) {
return;
}
visitedTrainIDs.add(connectedTrain.id);
connectedTrainByID.set(connectedTrain.id, connectedTrain);
pendingTrainIDs.push(connectedTrain.id);
});
}
const includedTrainIDSet = new Set(includedTrainIDs);
const candidateTrains = Array.from(connectedTrainByID.values()).filter(
(train) => !includedTrainIDSet.has(train.id),
);
const trainDataArray = candidateTrains.map((d) => d.TrainData);
const trainIDs = candidateTrains.map((d) => d.id);
const [firstStation] = trainData[0].split(",");
const [lastStation] = trainData[trainData.length - 1].split(",");
const head = [];
const tail = [];
const head: BoundaryTrain[] = [];
const tail: BoundaryTrain[] = [];
trainDataArray.forEach((data, i) => {
data.forEach((d) => {
@@ -47,9 +81,10 @@ export const useNearbyTrains = (trainNum, trainData) => {
});
});
setHeadStation(head);
setTailStation(tail);
}, [trainData, trainNum, getInfluencedTrainData]);
return { headStation, tailStation, nearTrainIDList };
return {
headStation: head,
tailStation: tail,
nearTrainIDList: nearTrainList.map((d) => d.id),
};
}, [getInfluencedTrainData, includedTrainIDs, trainData, trainNum]);
};
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useMemo } from 'react';
import { useStationList } from '@/stateBox/useStationList';
const computeStopStationIDs = (data: string[], stationList: any[][]): string[][] =>
@@ -13,14 +13,9 @@ const computeStopStationIDs = (data: string[], stationList: any[][]): string[][]
export const useStopStationIDs = (trainDataWithThrough: string[]) => {
const { stationList } = useStationList();
// 初回レンダリング時に同期的に計算することでActionSheetのアニメーション中の高さ変化を防ぐ
const [stopStationIDList, setStopStationIDList] = useState<string[][]>(() =>
computeStopStationIDs(trainDataWithThrough, stationList)
// station IDも表示データから直接導出し、effect由来の追加renderをなくす。
return useMemo(
() => computeStopStationIDs(trainDataWithThrough, stationList),
[trainDataWithThrough, stationList],
);
useEffect(() => {
setStopStationIDList(computeStopStationIDs(trainDataWithThrough, stationList));
}, [trainDataWithThrough, stationList]);
return stopStationIDList;
};
@@ -1,9 +1,9 @@
import { useState, useEffect } from 'react';
import { useMemo } from 'react';
import { lineListPair, stationIDPair } from '@/lib/getStationList';
import { useStationList } from '@/stateBox/useStationList';
import { OriginalStationList } from '@/lib/CommonTypes';
const computeThroughStations = (
const computeThroughStationsUnmeasured = (
trainData: string[],
stationList: any[][],
originalStationList: OriginalStationList
@@ -103,14 +103,20 @@ const computeThroughStations = (
export const useThroughStations = (trainData) => {
const { originalStationList, stationList } = useStationList();
// 初回レンダリング時に同期的に計算することでActionSheetのアニメーション中の高さ変化を防ぐ
const [state, setState] = useState(() =>
computeThroughStations(trainData, stationList, originalStationList)
// 純粋な派生値なのでstate/effectで一度古いリストを描画してから更新しない。
return useMemo(
() => computeThroughStations(trainData, stationList, originalStationList),
[trainData, stationList, originalStationList],
);
useEffect(() => {
setState(computeThroughStations(trainData, stationList, originalStationList));
}, [trainData, stationList, originalStationList]);
return { trainDataWithThrough: state.trainDataWithThrough, haveThrough: state.haveThrough };
};
function computeThroughStations(
trainData: string[],
stationList: any[][],
originalStationList: OriginalStationList,
): { trainDataWithThrough: string[]; haveThrough: boolean } {
return computeThroughStationsUnmeasured(
trainData,
stationList,
originalStationList,
);
}
@@ -1,8 +1,11 @@
import { useState, useEffect } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useTrainDiagram } from '@/stateBox/useAllTrainDiagram';
import { searchSpecialTrain } from '@/lib/eachTrainInfoCoreLib/searchSpecialTrain';
const parseTrainData = (trainNum: string, trainList: Record<string, string>) => {
const parseTrainData = (
trainNum: string,
trainList: Record<string, string>,
) => {
if (!trainNum) return { data: [], trueIDs: [] };
const TD = trainList[trainNum];
if (!TD) {
@@ -14,28 +17,30 @@ const parseTrainData = (trainNum: string, trainList: Record<string, string>) =>
export const useTrainDiagramData = (trainNum) => {
const { allTrainDiagram: trainList } = useTrainDiagram();
const [isManuallyExtended, setIsManuallyExtended] = useState(false);
const parsed = useMemo(
() => parseTrainData(trainNum, trainList),
[trainNum, trainList],
);
const [manualData, setManualData] = useState<{
trainNum: string;
data: string[];
} | null>(null);
// 初回レンダリング時にコンテキストから同期的にデータを取得することで
// ActionSheetのアニメーション中に高さが変わるのを防ぐ
const [trainData, setTrainData] = useState(() => parseTrainData(trainNum, trainList).data);
const [trueTrainID, setTrueTrainID] = useState(() => parseTrainData(trainNum, trainList).trueIDs);
// ダイヤ本体は入力から直接導出する。列車切替時に前の列車を一度描画して
// effectで新しい配列へ差し替える経路をなくし、リストの余分な再描画を防ぐ。
const trainData =
manualData?.trainNum === trainNum ? manualData.data : parsed.data;
useEffect(() => {
if (!trainNum) return;
const setTrainDataExtended = useCallback(
(data: string[]) => {
setManualData({ trainNum, data });
},
[trainNum],
);
// 手動で拡張されている場合は上書きしない
if (isManuallyExtended) return;
const { data, trueIDs } = parseTrainData(trainNum, trainList);
setTrueTrainID(trueIDs);
setTrainData(data);
}, [trainNum, trainList, isManuallyExtended]);
const setTrainDataExtended = (data) => {
setTrainData(data);
setIsManuallyExtended(true);
return {
trainData,
setTrainData: setTrainDataExtended,
trueTrainID: parsed.trueIDs,
};
return { trainData, setTrainData: setTrainDataExtended, trueTrainID };
};
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { AS } from '@/storageControl';
import { STORAGE_KEYS } from '@/constants';
import { logger } from '@/utils/logger';
@@ -7,7 +7,6 @@ import { useTrainMenu } from '@/stateBox/useTrainMenu';
import type { trainDataType } from '@/lib/trainPositionTextArray';
import { lineIdFromStationNumber } from '@/lib/checkDuplicateTrainData';
import { findReversalPoints } from '@/lib/eachTrainInfoCoreLib/findReversalPoints';
export const useTrainPosition = (
trainNum: string,
stopStationIDList: string[][]
@@ -18,13 +17,9 @@ export const useTrainPosition = (
getPosition,
} = useCurrentTrainData();
const { updatePermission } = useTrainMenu();
const [currentTrainData, setCurrentTrainData] = useState<trainDataType | undefined>();
const [currentTrainDataList, setCurrentTrainDataList] = useState<trainDataType[]>([]);
const [currentPosition, setCurrentPosition] = useState<string[]>([]);
const [currentPositionList, setCurrentPositionList] = useState<string[][]>([]);
const [trainPositionSwitch, setTrainPositionSwitch] = useState<string>('false');
useEffect(() => {
const currentTrainDataList = useMemo(() => {
const candidates = getCurrentStationDataList(trainNum);
const routeLineCounts = new Map<string, number>();
for (const stationNumber of stopStationIDList.flat()) {
@@ -35,14 +30,11 @@ export const useTrainPosition = (
.sort(([, countA], [, countB]) => countB - countA)
.map(([line]) => line);
const selected = getCurrentStationData(trainNum, routeLineIds);
const visible = updatePermission
return updatePermission
? candidates
: selected
? [selected]
: [];
logger.debug('Train position data:', visible);
setCurrentTrainDataList(visible);
setCurrentTrainData(visible[0]);
}, [
getCurrentStationData,
getCurrentStationDataList,
@@ -50,15 +42,16 @@ export const useTrainPosition = (
trainNum,
updatePermission,
]);
const currentTrainData = currentTrainDataList[0];
useEffect(() => {
if (!stopStationIDList.length || !currentTrainDataList.length) {
setCurrentPositionList([]);
setCurrentPosition([]);
return;
}
logger.debug('Train position data:', currentTrainDataList);
}, [currentTrainDataList]);
const positions = currentTrainDataList.map((train) => {
const currentPositionList = useMemo(() => {
if (!stopStationIDList.length || !currentTrainDataList.length) return [];
return currentTrainDataList.map((train) => {
const position = getPosition(train);
const next = position ? [...position] : [];
if (next.length > 1) {
@@ -79,9 +72,8 @@ export const useTrainPosition = (
}
return next;
});
setCurrentPositionList(positions);
setCurrentPosition(positions[0] ?? []);
}, [currentTrainDataList, stopStationIDList, getPosition]);
}, [currentTrainDataList, getPosition, stopStationIDList]);
const currentPosition = currentPositionList[0] ?? [];
useEffect(() => {
AS.getItem(STORAGE_KEYS.TRAIN_POSITION_SWITCH)
@@ -107,15 +99,21 @@ export const useTrainPosition = (
return merged;
};
const points =
trainPositionSwitch === 'true'
? mergePoints(true)
: stopStationIDList.map(() => false);
const points = useMemo(
() =>
trainPositionSwitch === 'true'
? mergePoints(true)
: stopStationIDList.map(() => false),
[currentPositionList, stopStationIDList, trainPositionSwitch],
);
const pointsDisplay =
trainPositionSwitch === 'true'
? mergePoints(false)
: stopStationIDList.map(() => false);
const pointsDisplay = useMemo(
() =>
trainPositionSwitch === 'true'
? mergePoints(false)
: stopStationIDList.map(() => false),
[currentPositionList, stopStationIDList, trainPositionSwitch],
);
return {
currentTrainData,
@@ -2,13 +2,14 @@ import React, { ComponentProps, FC, useEffect, useMemo, useState } from "react";
import { Ionicons } from "@expo/vector-icons";
import dayjs from "dayjs";
import { SheetManager } from "react-native-actions-sheet";
import { customTrainDataDetector } from "../../custom-train-data";
import { customTrainDataDetector } from "@/components/custom-train-data";
import { useCustomTrainData } from "@/stateBox/useAllTrainDiagram";
import { useInterval } from "@/lib/useInterval";
import type { NavigateFunction } from "@/types";
import { OperationLogs } from "@/lib/CommonTypes";
import type { IconDisplayMode } from "@/lib/iconDisplayMode";
import { resolveTrainIconEntries } from "@/lib/trainIconEntries";
import { normalizeVehicleInfoUrl } from "@/lib/vehicleInfoUrl";
import { TrainIconStack } from "./TrainIconStack";
type GlyphNames = ComponentProps<typeof Ionicons>["name"];
@@ -122,11 +123,22 @@ export const TrainIconStatus: FC<Props> = (props) => {
hidden={!!anpanmanStatus && showIcon}
statusIcon={anpanmanStatus}
onPressEntry={(entry) => {
navigate("howto", {
info: entry.vehicle_info_url,
goTo: from == "LED" ? "menu" : from,
});
SheetManager.hide("EachTrainInfo");
const vehicleInfoUrl = normalizeVehicleInfoUrl(entry.vehicle_info_url);
if (!vehicleInfoUrl) return;
void SheetManager.hide("EachTrainInfo")
.then(() => {
navigate("generalWebView", {
uri: vehicleInfoUrl,
useExitButton: true,
});
})
.catch(() => {
navigate("generalWebView", {
uri: vehicleInfoUrl,
useExitButton: true,
});
});
}}
/>
);
@@ -1,7 +1,7 @@
import React, { FC, useEffect, useState } from "react";
import { Ionicons } from "@expo/vector-icons";
import { SheetManager } from "react-native-actions-sheet";
import { getType } from "../../../lib/eachTrainInfoCoreLib/getType";
import { getType } from "@/lib/eachTrainInfoCoreLib/getType";
import { useThemeColors } from "@/lib/theme";
import type { NavigateFunction } from "@/types";
@@ -19,7 +19,7 @@ import LottieView from "lottie-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ViewShot from "react-native-view-shot";
import * as Sharing from "expo-sharing";
import { useTrainDelayData } from "../../stateBox/useTrainDelayData";
import { useTrainDelayData } from "@/stateBox/useTrainDelayData";
import { BottomButtons } from "./JRSTraInfo/BottomButtons";
import { useThemeColors } from "@/lib/theme";
import { useResponsive } from "@/lib/responsive";
@@ -127,7 +127,7 @@ export const JRSTraInfo = () => {
autoPlay
loop
style={{ width: 150, height: 150, backgroundColor: colors.surface }}
source={require("../../assets/51690-loading-diamonds.json")}
source={require("@/assets/51690-loading-diamonds.json")}
/>
</View>
) : delayData ? (
@@ -3,12 +3,12 @@ import {
View,
Text,
TouchableOpacity,
Linking,
StyleProp,
ViewStyle,
} from "react-native";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useThemeColors } from "@/lib/theme";
import { openExternalUrl } from "@/lib/openExternalUrl";
export const BottomButtons: FC<{ onCapture: () => void }> = ({ onCapture }) => {
const { fixed } = useThemeColors();
@@ -35,7 +35,7 @@ export const BottomButtons: FC<{ onCapture: () => void }> = ({ onCapture }) => {
>
<TouchableOpacity
style={{ ...styles, flex: 1 }}
onPress={() => Linking.openURL("https://mstdn.y-zu.org/@JRSTraInfoEX")}
onPress={() => openExternalUrl("https://mstdn.y-zu.org/@JRSTraInfoEX")}
>
<MaterialCommunityIcons name="mastodon" color={fixed.textOnPrimary} size={30} />
<View style={{ flex: 1 }} />
@@ -4,7 +4,6 @@ import {
Platform,
Text,
TouchableOpacity,
Linking,
useWindowDimensions,
} from "react-native";
import ActionSheet from "react-native-actions-sheet";
@@ -15,6 +14,7 @@ import { NewsReleaseInfoBox } from "../Menu/NewsReleaseInfoBox";
import { useThemeColors } from "@/lib/theme";
import { useResponsive } from "@/lib/responsive";
import type { NewsReleaseInfoPayload } from "@/types/actionSheets";
import { openExternalUrl } from "@/lib/openExternalUrl";
type Props = {
payload: NewsReleaseInfoPayload;
@@ -115,7 +115,7 @@ const NewsReleaseInfoContent: FC<ContentProps> = ({ payload: { navigate }, scrol
{/* 下部固定ボタン(ScrollViewの外、画面の下固定) */}
<View style={{ padding: 10, backgroundColor: fixed.primary }}>
<TouchableOpacity
onPress={() => Linking.openURL("https://www.jr-shikoku.co.jp/03_news/press/")}
onPress={() => openExternalUrl("https://www.jr-shikoku.co.jp/03_news/press/")}
style={{
paddingVertical: 12,
paddingLeft: 14,
@@ -4,10 +4,10 @@ import ActionSheet ,{ ScrollView } from "react-native-actions-sheet";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ListItem } from "@rneui/themed";
import { MaterialCommunityIcons, Foundation } from "@expo/vector-icons";
import { Linking } from "react-native";
import TouchableScale from "react-native-touchable-scale";
import { useThemeColors } from "@/lib/theme";
import { useSheetMaxHeight } from "./useSheetMaxHeight";
import { openExternalUrl } from "@/lib/openExternalUrl";
export const Social = () => {
const actionSheetRef = useRef(null);
const insets = useSafeAreaInsets();
@@ -66,8 +66,8 @@ export const Social = () => {
</View>
<ListItem
bottomDivider
onPress={() => Linking.openURL("tel:0570-00-4592")}
// @ts-ignore: TouchableScale props passed through Component
onPress={() => openExternalUrl("tel:0570-00-4592")}
// @ts-expect-error RNEUIのListItem型はComponentに渡したTouchableScaleのpropsを含まない
friction={90}
tension={100}
activeScale={0.95}
@@ -83,7 +83,7 @@ export const Social = () => {
0570-00-4592(8:00〜20:00 年中無休)
</ListItem.Subtitle>
</ListItem.Content>
{/* @ts-ignore: ListItem.Chevron doesn't expose color prop */}
{/* @ts-expect-error RNEUIのListItem.Chevron型はcolorを公開していないが、実装はIconへ渡す */}
<ListItem.Chevron color={"white"} />
</ListItem>
<ScrollView
@@ -152,9 +152,9 @@ export const Social = () => {
].map((d) => (
<ListItem
bottomDivider
onPress={() => Linking.openURL(d.url)}
onPress={() => openExternalUrl(d.url)}
key={d.url}
// @ts-ignore: TouchableScale props passed through Component
// @ts-expect-error RNEUIのListItem型はComponentに渡したTouchableScaleのpropsを含まない
friction={90} //
tension={100} // These props are passed to the parent component (here TouchableScale)
activeScale={0.95} //
@@ -1,7 +1,6 @@
import React, { useState, useEffect } from "react";
import {
View,
Linking,
BackHandler,
Platform,
useWindowDimensions,
@@ -9,11 +8,11 @@ import {
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ActionSheet, { SheetManager } from "react-native-actions-sheet";
import type { SheetProps } from "react-native-actions-sheet";
import Sign from "../../components/駅名表/Sign";
import Sign from "@/components/駅名表/Sign";
import { getPDFViewURL } from "../../lib/getPdfViewURL";
import { useBusAndTrainData } from "../../stateBox/useBusAndTrainData";
import { AS } from "../../storageControl";
import { getPDFViewURL } from "@/lib/getPdfViewURL";
import { useBusAndTrainData } from "@/stateBox/useBusAndTrainData";
import { AS } from "@/storageControl";
import { STORAGE_KEYS } from "@/constants";
import { StationMapButton } from "./StationDeteilView/StationMapButton";
import { TrainBusButton } from "./StationDeteilView/TrainBusButton";
@@ -26,6 +25,7 @@ import { useTrainMenu } from "@/stateBox/useTrainMenu";
import { useThemeColors } from "@/lib/theme";
import { useResponsive } from "@/lib/responsive";
import { useSheetMaxHeight } from "./useSheetMaxHeight";
import { openExternalUrl } from "@/lib/openExternalUrl";
export const StationDeteilView = ({ payload }: SheetProps<"StationDetailView">) => {
if (!payload) return <></>;
@@ -102,15 +102,14 @@ export const StationDeteilView = ({ payload }: SheetProps<"StationDetailView">)
stationID={station.StationNumber}
oP={() => {
usePDFView == "true"
? Linking.openURL(station.StationTimeTable)
: navigate("howto", {
info,
goTo,
useShow,
? openExternalUrl(station.StationTimeTable)
: navigate("generalWebView", {
uri: info,
onExit: goTo != "NearTrainDiagramView" ? useShow : undefined,
});
onExit();
}}
oLP={() => Linking.openURL(station.StationTimeTable)}
oLP={() => openExternalUrl(station.StationTimeTable)}
/>
</View>
<View style={{ flexDirection: "row" }}>
@@ -165,10 +164,9 @@ export const StationDeteilView = ({ payload }: SheetProps<"StationDetailView">)
<TrainBusButton
address={trainBus.address}
press={() => {
navigate("howto", {
info: trainBus.address,
goTo,
useShow,
navigate("generalWebView", {
uri: trainBus.address,
onExit: goTo != "NearTrainDiagramView" ? useShow : undefined,
});
onExit();
}}
@@ -1,7 +1,8 @@
import { FC } from "react";
import { TouchableOpacity, View, Text, Linking } from "react-native";
import { TouchableOpacity, View, Text } from "react-native";
import { useThemeColors } from "@/lib/theme";
import type { NavigateFunction } from "@/types";
import { openExternalUrl } from "@/lib/openExternalUrl";
type Props = {
navigate: NavigateFunction;
address: string;
@@ -25,10 +26,13 @@ export const 駅構内図:FC<Props> = (props) => {
flex: 1,
}}
onPress={() => {
navigate("howto", { info, goTo, useShow });
navigate("generalWebView", {
uri: info,
onExit: goTo != "NearTrainDiagramView" ? useShow : undefined,
});
onExit();
}}
onLongPress={() => Linking.openURL(info)}
onLongPress={() => openExternalUrl(info)}
>
<View style={{ flex: 1 }} />
<Text
@@ -1,8 +1,8 @@
import React from "react";
import { Linking } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { TicketBox } from "@/components/atom/TicketBox";
import { useThemeColors } from "@/lib/theme";
import { openExternalUrl } from "@/lib/openExternalUrl";
export const StationMapButton = ({stationMap}) => {
const { fixed } = useThemeColors();
return (
@@ -10,7 +10,7 @@ export const StationMapButton = ({stationMap}) => {
backgroundColor={"#888A85"}
icon={<Ionicons name="map" color={fixed.textOnPrimary} size={50} />}
flex={1}
onPressButton={() => Linking.openURL(stationMap)}
onPressButton={() => openExternalUrl(stationMap)}
>
Map
</TicketBox>
@@ -1,9 +1,9 @@
import React, { FC } from "react";
import { Linking } from "react-native";
import { FontAwesome } from "@expo/vector-icons";
import { TicketBox } from "@/components/atom/TicketBox";
import { useThemeColors } from "@/lib/theme";
import type { NavigateFunction } from "@/types";
import { openExternalUrl } from "@/lib/openExternalUrl";
type Props = {
info: string;
address: string;
@@ -23,11 +23,14 @@ export const StationTimeTableButton: FC<Props> = (props) => {
flex={1}
onPressButton={() => {
usePDFView == "true"
? Linking.openURL(address)
: navigate("howto", { info, goTo, useShow });
? openExternalUrl(address)
: navigate("generalWebView", {
uri: info,
onExit: goTo != "NearTrainDiagramView" ? useShow : undefined,
});
onExit();
}}
onLongPressButton={() => Linking.openURL(address)}
onLongPressButton={() => openExternalUrl(address)}
>
時刻表
</TicketBox>
@@ -1,8 +1,8 @@
import React, { FC } from "react";
import { Linking } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { TicketBox } from "@/components/atom/TicketBox";
import { useThemeColors } from "@/lib/theme";
import { openExternalUrl } from "@/lib/openExternalUrl";
type Props = {
address: string;
press: () => void;
@@ -15,7 +15,7 @@ export const TrainBusButton: FC<Props> = ({ address, press }) => {
icon={<Ionicons name="bus" color={fixed.textOnPrimary} size={50} />}
flex={1}
onPressButton={press}
onLongPressButton={() => Linking.openURL(address)}
onLongPressButton={() => openExternalUrl(address)}
>
並行バス
</TicketBox>
@@ -1,9 +1,9 @@
import React, { FC } from "react";
import { Linking } from "react-native";
import { Foundation } from "@expo/vector-icons";
import { TicketBox } from "@/components/atom/TicketBox";
import { useThemeColors } from "@/lib/theme";
import type { NavigateFunction } from "@/types";
import { openExternalUrl } from "@/lib/openExternalUrl";
type Props = {
navigate: NavigateFunction;
info: string;
@@ -20,10 +20,13 @@ export const WebSiteButton: FC<Props> = (Props) => {
icon={<Foundation name="web" color={fixed.textOnPrimary} size={50} />}
flex={1}
onPressButton={() => {
navigate("howto", { info, goTo, useShow });
navigate("generalWebView", {
uri: info,
onExit: goTo != "NearTrainDiagramView" ? useShow : undefined,
});
onExit();
}}
onLongPressButton={() => Linking.openURL(info)}
onLongPressButton={() => openExternalUrl(info)}
>
web
</TicketBox>
@@ -5,7 +5,6 @@ import {
TouchableOpacity,
StyleSheet,
Platform,
Linking,
Image,
Animated,
useWindowDimensions,
@@ -27,6 +26,7 @@ import {
import ViewShot from "react-native-view-shot";
import * as Sharing from "expo-sharing";
import dayjs from "dayjs";
import { openExternalUrl } from "@/lib/openExternalUrl";
export type { TrainDataSourcesPayload } from "@/types/actionSheets";
@@ -762,7 +762,7 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
const url =
activeElesiteGroup?.timetableUrl || "https://www.elesite-next.com/";
SheetManager.hide("TrainDataSources");
Linking.openURL(url);
openExternalUrl(url);
}}
/>
)}
@@ -12,8 +12,8 @@ import ActionSheet, { SheetManager } from "react-native-actions-sheet";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ViewShot from "react-native-view-shot";
import * as Sharing from "expo-sharing";
import icons from "../../assets/icons/icons";
import hubIcons from "../../assets/unyoHubIcons/icons";
import icons from "@/assets/icons/icons";
import hubIcons from "@/assets/unyoHubIcons/icons";
import { getAppIconName } from "expo-alternate-app-icons";
import { AS } from "@/storageControl";
@@ -8,10 +8,10 @@ import {
} from "react-native";
import ActionSheet, { SheetManager } from "react-native-actions-sheet";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTrainMenu } from "../../stateBox/useTrainMenu";
import { useCurrentTrainView } from "../../stateBox/useCurrentTrain";
import lineColorList from "../../assets/originData/lineColorList";
import { stationIDPair, lineListPair } from "../../lib/getStationList";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
import { useCurrentTrainView } from "@/stateBox/useCurrentTrain";
import lineColorList from "@/assets/originData/lineColorList";
import { stationIDPair, lineListPair } from "@/lib/getStationList";
import { useThemeColors } from "@/lib/theme";
import { useSheetMaxHeight } from "./useSheetMaxHeight";
+13 -5
View File
@@ -1,4 +1,4 @@
import React, { useState, useRef, FC } from "react";
import React, { useEffect, useState, useRef, FC } from "react";
import {
Animated,
View,
@@ -7,7 +7,6 @@ import {
FlatList,
TextInput,
ScrollView,
Linking,
} from "react-native";
import {
useCustomTrainData,
@@ -33,6 +32,7 @@ import { InfogramText } from "./ActionSheetComponents/EachTrainInfoCore/HeaderTe
import { resolveTrainIconEntries } from "@/lib/trainIconEntries";
import { TrainIconStack } from "./ActionSheetComponents/EachTrainInfoCore/TrainIconStack";
import { formatWrappedTrainType, shouldWrapTrainType } from "@/lib/trainTypeWrap";
import { openExternalUrl } from "@/lib/openExternalUrl";
export const AllTrainDiagramView: FC = () => {
const { colors, fixed } = useThemeColors();
@@ -41,8 +41,16 @@ export const AllTrainDiagramView: FC = () => {
const iconDisplayMode = normalizeIconDisplayMode(iconSetting);
const tabBarHeight = useBottomTabBarHeight();
const { keyList, allTrainDiagram } = useTrainDiagram();
const { allCustomTrainData } = useCustomTrainData();
const { getTodayOperationByTrainId } = useOperationLogs();
const { allCustomTrainData, activateCustomTrainData } = useCustomTrainData();
const {
getTodayOperationByTrainId,
activateOperationLogs,
} = useOperationLogs();
useEffect(() => {
activateCustomTrainData();
activateOperationLogs();
}, [activateCustomTrainData, activateOperationLogs]);
const [input, setInput] = useState(""); // 文字入力
const [useStationName, setUseStationName] = useState(false);
const [useRegex, setUseRegex] = useState(false);
@@ -331,7 +339,7 @@ export const AllTrainDiagramView: FC = () => {
<Text
style={{ ...regexTextButtonStyle, backgroundColor: "green" }}
onPress={() =>
Linking.openURL(
openExternalUrl(
"https://qiita.com/tossh/items/635aea9a529b9deb3038"
)
}
@@ -1,8 +1,8 @@
import React from "react";
import dayjs from "dayjs";
import { FlexWidget, OverlapWidget, SvgWidget, TextWidget } from "react-native-android-widget";
import { AS } from "../../storageControl";
import { STORAGE_KEYS } from "../../constants";
import { AS } from "@/storageControl";
import { STORAGE_KEYS } from "@/constants";
import { WidgetColors, widgetLightColors } from "./widget-theme";
type LastFelicaSnapshot = {
+20 -15
View File
@@ -11,23 +11,28 @@ import type { OperationInfoSnapshot } from "@/types";
export const getInfoString = async () => {
const time = dayjs().format("HH:mm");
const response = await fetch(API_ENDPOINTS.OPERATION_INFO, {
cache: "no-store",
});
if (!response.ok) {
throw new Error(
`Operation information request failed: ${response.status}`
);
try {
const response = await fetch(API_ENDPOINTS.OPERATION_INFO, {
cache: "no-store",
});
if (!response.ok) {
throw new Error(
`Operation information request failed: ${response.status}`
);
}
const snapshot = (await response.json()) as OperationInfoSnapshot;
const operationInfoText = snapshot.compatibility?.operationInfoText ?? "";
const text = operationInfoText === "" ? null : operationInfoText.split("^");
return { time, text, failed: false };
} catch {
// nullだと「通常運行中です。」と表示されるため、取得失敗は別に扱う
return { time, text: null, failed: true };
}
const snapshot = (await response.json()) as OperationInfoSnapshot;
const operationInfoText = snapshot.compatibility?.operationInfoText ?? "";
const text = operationInfoText === "" ? null : operationInfoText.split("^");
return { time, text };
};
export function InfoWidget({ time, text, colors = widgetLightColors }: { time: string; text: string[] | string | null; colors?: WidgetColors }) {
export function InfoWidget({ time, text, failed = false, colors = widgetLightColors }: { time: string; text: string[] | string | null; failed?: boolean; colors?: WidgetColors }) {
return (
<FlexWidget
style={{
@@ -98,7 +103,7 @@ export function InfoWidget({ time, text, colors = widgetLightColors }: { time: s
color: colors.text,
fontSize: 20,
}}
text="通常運行中です。"
text={failed ? "運行情報を取得できませんでした。" : "通常運行中です。"}
/>
)}
</ListWidget>
+1 -1
View File
@@ -3,7 +3,7 @@ import { FlexWidget, TextWidget } from "react-native-android-widget";
import { getDelayData } from "./TraInfoEXWidget";
import { getInfoString } from "./InfoWidget";
import { getFelicaQuickAccessData } from "./FelicaQuickAccessWidget";
import { isAvailable as isNfcAvailable } from "../../modules/expo-felica-reader/src";
import { isAvailable as isNfcAvailable } from "@/modules/expo-felica-reader/src";
import { WidgetColors, widgetLightColors } from "./widget-theme";
export async function getShortcutData() {
+22 -12
View File
@@ -11,18 +11,28 @@ import { API_ENDPOINTS } from "@/constants";
export const getDelayData = async () => {
// Fetch data from the server
const time = dayjs().format("HH:mm");
const delayString = await fetch(API_ENDPOINTS.DELAY_INFO_LEGACY)
.then((response) => response.text())
.then((data) => {
if (data !== "") {
return data.split("^");
}
return null;
});
//ToastAndroid.show(`${delayString}`, ToastAndroid.SHORT);
return { time, delayString };
try {
const delayString = await fetch(API_ENDPOINTS.DELAY_INFO_LEGACY)
.then((response) => {
if (!response.ok) {
throw new Error(`Delay information request failed: ${response.status}`);
}
return response.text();
})
.then((data) => {
if (data !== "") {
return data.split("^");
}
return null;
});
//ToastAndroid.show(`${delayString}`, ToastAndroid.SHORT);
return { time, delayString, failed: false };
} catch {
// nullだと「遅れはありません」と表示されるため、取得失敗は別に扱う
return { time, delayString: null, failed: true };
}
};
export function TraInfoEXWidget({ time, delayString, colors = widgetLightColors }: { time: string; delayString: string[] | null; colors?: WidgetColors }) {
export function TraInfoEXWidget({ time, delayString, failed = false, colors = widgetLightColors }: { time: string; delayString: string[] | null; failed?: boolean; colors?: WidgetColors }) {
return (
<FlexWidget
style={{
@@ -104,7 +114,7 @@ export function TraInfoEXWidget({ time, delayString, colors = widgetLightColors
color: colors.text,
fontSize: 20,
}}
text="現在、5分以上の遅れはありません。"
text={failed ? "遅延情報を取得できませんでした。" : "現在、5分以上の遅れはありません。"}
/>
)}
</ListWidget>
@@ -8,16 +8,8 @@ import {
import { ShortcutWidget, getShortcutData } from "./ShortcutWidget";
import { StrangeTrainWidget, getStrangeTrainData } from "./StrangeTrainWidget";
import { widgetLightColors, widgetDarkColors } from "./widget-theme";
import { AS } from "../../storageControl";
import { STORAGE_KEYS } from "../../constants";
export const nameToWidget = {
JR_shikoku_train_info: TraInfoEXWidget,
Info_Widget: InfoWidget,
JR_shikoku_apps_shortcut: ShortcutWidget,
JR_shikoku_felica_balance: FelicaQuickAccessWidget,
JR_shikoku_train_strange: StrangeTrainWidget,
};
import { AS } from "@/storageControl";
import { STORAGE_KEYS } from "@/constants";
type ColorThemePref = "light" | "system" | "dark";
@@ -95,18 +87,18 @@ export async function widgetTaskHandler(props) {
}
if (name === "JR_shikoku_info") {
const { time, text } = await getInfoString();
const { time, text, failed } = await getInfoString();
renderWithTheme(renderWidget, theme, (isDark) => (
<InfoWidget time={time} text={text && text.toString()} colors={isDark ? widgetDarkColors : widgetLightColors} />
<InfoWidget time={time} text={text && text.toString()} failed={failed} colors={isDark ? widgetDarkColors : widgetLightColors} />
));
break;
}
// JR_shikoku_train_info and default
{
const { time, delayString } = await getDelayData();
const { time, delayString, failed } = await getDelayData();
renderWithTheme(renderWidget, theme, (isDark) => (
<TraInfoEXWidget time={time} delayString={delayString} colors={isDark ? widgetDarkColors : widgetLightColors} />
<TraInfoEXWidget time={time} delayString={delayString} failed={failed} colors={isDark ? widgetDarkColors : widgetLightColors} />
));
}
break;
+4 -4
View File
@@ -1,10 +1,10 @@
import React, { useEffect, useRef } from "react";
import { Animated, StyleSheet, View } from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import type { RootTabParamList } from "../../types/navigation";
import lineColorList from "../../assets/originData/lineColorList";
import { stationIDPair } from "../../lib/getStationList";
import { fixedColors } from "../../lib/theme/colors";
import type { RootTabParamList } from "@/types/navigation";
import lineColorList from "@/assets/originData/lineColorList";
import { stationIDPair } from "@/lib/getStationList";
import { fixedColors } from "@/lib/theme/colors";
export type TabBarBackgroundProps = {
routeName: keyof RootTabParamList;
+250
View File
@@ -0,0 +1,250 @@
import {
Component,
useContext,
useState,
useSyncExternalStore,
type ErrorInfo,
type ReactNode,
} from "react";
import { Platform, ScrollView, Text, TouchableOpacity, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import * as Updates from "expo-updates";
import Constants from "expo-constants";
import {
SafeAreaInsetsContext,
initialWindowMetrics,
} from "react-native-safe-area-context";
import { captureTelemetryException } from "@/lib/observability/sentry";
import { formatErrorReport } from "@/lib/errorReport";
import {
ErrorBoundaryTestError,
disarmErrorBoundaryTest,
getArmedErrorBoundaryTestScope,
subscribeErrorBoundaryTest,
} from "@/lib/errorBoundaryTest";
type Props = {
/** 捕捉位置。Sentryのtagとエラー報告に出る("root"、タブ名など) */
scope: string;
children: ReactNode;
};
type State = {
error: unknown;
hasError: boolean;
componentStack: string | null;
sentryEventId: string | null;
occurredAt: Date | null;
};
const initialState: State = {
error: null,
hasError: false,
componentStack: null,
sentryEventId: null,
occurredAt: null,
};
/**
* 子の描画中に起きた例外を捕捉し、白画面の代わりにエラー画面を出す。
*
* イベントハンドラや非同期処理、ネイティブのクラッシュは捕捉できない。
* エラー画面はテーマContextなど壊れた可能性のある仕組みに依存せず、固定色で描く。
*/
export class AppErrorBoundary extends Component<Props, State> {
state: State = initialState;
static getDerivedStateFromError(error: unknown): Partial<State> {
return { error, hasError: true, occurredAt: new Date() };
}
componentDidCatch(error: unknown, info: ErrorInfo) {
const componentStack = info.componentStack ?? null;
const isTest = error instanceof ErrorBoundaryTestError;
// 再表示で同じテストエラーが再発しないよう、捕捉した時点で解除する
if (isTest) disarmErrorBoundaryTest();
let sentryEventId: string | null = null;
try {
sentryEventId = captureTelemetryException(error, {
tags: {
error_boundary: this.props.scope,
error_boundary_test: String(isTest),
},
contexts: { react: { componentStack } },
});
} catch {
// Sentry送信の失敗でエラー画面の表示まで失敗させない
}
this.setState({ componentStack, sentryEventId });
}
private reset = () => this.setState(initialState);
render() {
if (!this.state.hasError) {
return (
<>
<ErrorBoundaryTestTrigger scope={this.props.scope} />
{this.props.children}
</>
);
}
const report = formatErrorReport({
scope: this.props.scope,
error: this.state.error,
componentStack: this.state.componentStack,
sentryEventId: this.state.sentryEventId,
occurredAt: this.state.occurredAt ?? new Date(),
environment: {
platform: Platform.OS,
platformVersion: String(Platform.Version),
appVersion: Constants.expoConfig?.version,
runtimeVersion: Updates.runtimeVersion,
updateId: Updates.updateId,
},
});
return (
<ErrorFallback
isRoot={this.props.scope === "root"}
report={report}
onRetry={this.reset}
/>
);
}
}
/** 研究ツールから指定された scope のときだけ、描画中に例外を投げる。 */
const ErrorBoundaryTestTrigger = ({ scope }: { scope: string }) => {
const armedScope = useSyncExternalStore(
subscribeErrorBoundaryTest,
getArmedErrorBoundaryTestScope,
);
if (armedScope === scope) throw new ErrorBoundaryTestError(scope);
return null;
};
const colors = {
background: "#f5f5f5",
text: "#222222",
subText: "#555555",
reportBackground: "#ffffff",
border: "#dddddd",
primary: "#0099cc",
onPrimary: "#ffffff",
};
const ErrorFallback = ({
isRoot,
report,
onRetry,
}: {
isRoot: boolean;
report: string;
onRetry: () => void;
}) => {
const [copied, setCopied] = useState(false);
// ルートはSafeAreaProviderの外側なので、起動時の値で代替する
const insets =
useContext(SafeAreaInsetsContext) ?? initialWindowMetrics?.insets;
const copyReport = () => {
Clipboard.setStringAsync(report)
.then(() => setCopied(true))
.catch(() => setCopied(false));
};
const reloadApp = () => {
// 開発ビルドなどでreloadできない場合は、再表示で代替する
Updates.reloadAsync().catch(onRetry);
};
return (
<View
style={{
flex: 1,
backgroundColor: colors.background,
paddingTop: (insets?.top ?? 0) + 16,
paddingHorizontal: 16,
// タブ内はタブバーの上に描かれるので、下の余白はルートだけ確保する
paddingBottom: (isRoot ? insets?.bottom ?? 0 : 0) + 16,
}}
>
<Text style={{ color: colors.text, fontSize: 18, fontWeight: "bold" }}>
{isRoot ? "アプリでエラーが発生しました" : "この画面でエラーが発生しました"}
</Text>
<Text style={{ color: colors.subText, fontSize: 14, marginTop: 8 }}>
{isRoot
? "再読み込みで復帰できる場合があります。"
: "再表示で復帰できる場合があります。他のタブは引き続き使えます。"}
{"\n"}
不具合の報告には、下の「エラー内容をコピー」を使ってください。
</Text>
<View style={{ flexDirection: "row", marginTop: 16, gap: 8 }}>
<FallbackButton
label={isRoot ? "再読み込み" : "再表示"}
onPress={isRoot ? reloadApp : onRetry}
primary
/>
<FallbackButton
label={copied ? "コピーしました" : "エラー内容をコピー"}
onPress={copyReport}
/>
</View>
<ScrollView
style={{
flex: 1,
marginTop: 16,
backgroundColor: colors.reportBackground,
borderColor: colors.border,
borderWidth: 1,
borderRadius: 6,
}}
contentContainerStyle={{ padding: 12 }}
>
<Text selectable style={{ color: colors.text, fontSize: 12 }}>
{report}
</Text>
</ScrollView>
</View>
);
};
const FallbackButton = ({
label,
onPress,
primary = false,
}: {
label: string;
onPress: () => void;
primary?: boolean;
}) => (
<TouchableOpacity
accessibilityRole="button"
onPress={onPress}
style={{
flex: 1,
minHeight: 44,
borderRadius: 6,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 12,
backgroundColor: primary ? colors.primary : colors.reportBackground,
borderColor: colors.primary,
borderWidth: 1,
}}
>
<Text
style={{
color: primary ? colors.onPrimary : colors.primary,
fontSize: 14,
fontWeight: "bold",
}}
>
{label}
</Text>
</TouchableOpacity>
);
+3 -1
View File
@@ -2,7 +2,7 @@ import React from "react";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { SheetProvider } from "react-native-actions-sheet";
import { AppContainer } from "../Apps";
import { AppContainer } from "@/AppContainer";
import { AllTrainDiagramProvider } from "../stateBox/useAllTrainDiagram";
import { NotificationProvider } from "../stateBox/useNotifications";
import { UserPositionProvider } from "../stateBox/useUserPosition";
@@ -16,6 +16,7 @@ import { BusAndTrainDataProvider } from "../stateBox/useBusAndTrainData";
import { DeviceOrientationChangeProvider } from "../stateBox/useDeviceOrientationChange";
import { buildProvidersTree } from "../lib/providerTreeProvider";
import { AppThemeProvider } from "../lib/theme";
import { markStartupOnce } from "../lib/devStartupPerformance";
import StatusbarDetect from "../StatusbarDetect";
import "./ActionSheetComponents/sheets";
@@ -34,6 +35,7 @@ const ProviderTree = buildProvidersTree([
]);
export function AppRootLayout(): React.ReactElement {
markStartupOnce("root-layout-render", "AppRootLayout render");
return (
<AppThemeProvider>
<DeviceOrientationChangeProvider>
+3
View File
@@ -26,6 +26,7 @@ import { MapsButton } from "./Apps/MapsButton";
import { ReloadButton } from "./Apps/ReloadButton";
import { useStationList } from "../stateBox/useStationList";
import { positionsLifecycleRef } from "../lib/rootNavigation";
import { usePositionsStartupReady } from "../lib/positionsStartupContext";
import { FixedPositionBox } from "./Apps/FixedPositionBox";
import { PlaybackTimeline } from "./Apps/PlaybackTimeline";
import { RecordingStatusBar } from "./Apps/RecordingStatusBar";
@@ -43,6 +44,7 @@ export default function Apps() {
const { mapSwitch, selectedLine } = useTrainMenu();
const isDark = useColorScheme() === "dark";
const isFocused = useIsFocused();
const { notifyInitialLoadReady } = usePositionsStartupReady();
const [hasActivatedScreen, setHasActivatedScreen] = React.useState(false);
const [hasActivatedWebView, setHasActivatedWebView] = React.useState(false);
const [hasStableWebViewSession, setHasStableWebViewSession] =
@@ -332,6 +334,7 @@ export default function Apps() {
<AppsWebView
openStationACFromEachTrainInfo={openStationACFromEachTrainInfo}
onInitialLoadReady={() => {
notifyInitialLoadReady();
if (hasStableWebViewSession) return;
positionsScreenTelemetry.info(
"positions webview became stable",
@@ -57,7 +57,7 @@ import { STORAGE_KEYS } from "@/constants";
// ── 遅延速報サウンド設定 ──
// 将来のファイル差し替えに備えて定数で管理
const DELAY_ANNOUNCEMENT_SOUND = require("../../../assets/sound/delay-support.wav");
const DELAY_ANNOUNCEMENT_SOUND = require("@/assets/sound/delay-support.wav");
// 遅延案内の再生間隔: 120〜180 秒をランダムで選択(2〜3分ごと)
const getAnnouncementIntervalMs = () =>
(Math.floor(Math.random() * 60) + 120) * 1000;
@@ -409,7 +409,6 @@ export const FixedStation: FC<props> = ({ stationID }) => {
goTo: "menu",
onExit: () => SheetManager.hide("StationDetailView"),
};
//@ts-ignore
SheetManager.show("StationDetailView", { payload });
}}
>
+1 -1
View File
@@ -7,7 +7,7 @@ import {
TextStyle,
} from "react-native";
import { useThemeColors } from "@/lib/theme";
import { useTrainMenu } from "../../stateBox/useTrainMenu";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useResponsive } from "@/lib/responsive";
+2 -2
View File
@@ -4,8 +4,8 @@ import { Ionicons } from "@expo/vector-icons";
import { useThemeColors } from "@/lib/theme";
import * as Updates from "expo-updates";
import Constants from "expo-constants";
import { useCurrentTrainView } from "../../stateBox/useCurrentTrain";
import { useTrainMenu } from "../../stateBox/useTrainMenu";
import { useCurrentTrainView } from "@/stateBox/useCurrentTrain";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useResponsive } from "@/lib/responsive";
+3 -3
View File
@@ -9,9 +9,9 @@ import {
import { Ionicons } from "@expo/vector-icons";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import dayjs from "dayjs";
import { useTrainRecording } from "../../stateBox/useTrainMenu";
import { useThemeColors } from "../../lib/theme";
import { useResponsive } from "../../lib/responsive";
import { useTrainRecording } from "@/stateBox/useTrainMenu";
import { useThemeColors } from "@/lib/theme";
import { useResponsive } from "@/lib/responsive";
/**
* 再生中に走行位置WebViewの上部に表示するタイムラインコントローラー。
+3 -3
View File
@@ -2,9 +2,9 @@ import React, { FC, useEffect, useRef, useState } from "react";
import { View, Text, AppState, InteractionManager } from "react-native";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTrainRecording } from "../../stateBox/useTrainMenu";
import { useThemeColors } from "../../lib/theme";
import { useResponsive } from "../../lib/responsive";
import { useTrainRecording } from "@/stateBox/useTrainMenu";
import { useThemeColors } from "@/lib/theme";
import { useResponsive } from "@/lib/responsive";
const KEEP_AWAKE_TAG = "recording-status-bar";
+1 -1
View File
@@ -7,7 +7,7 @@ import {
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useThemeColors } from "@/lib/theme";
import { useTrainMenu } from "../../stateBox/useTrainMenu";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useResponsive } from "@/lib/responsive";
+125 -44
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef } from "react";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { Platform } from "react-native";
import { WebView } from "react-native-webview";
@@ -8,27 +8,44 @@ import {
lineListPair,
stationIDPair,
stationNamePair,
} from "../../lib/getStationList";
import { checkDuplicateTrainData } from "../../lib/checkDuplicateTrainData";
import { useFavoriteStation } from "../../stateBox/useFavoriteStation";
} from "@/lib/getStationList";
import { checkDuplicateTrainData } from "@/lib/checkDuplicateTrainData";
import { useFavoriteStation } from "@/stateBox/useFavoriteStation";
import {
useCurrentTrainData,
useCurrentTrainView,
} from "../../stateBox/useCurrentTrain";
import { useDeviceOrientationChange } from "../../stateBox/useDeviceOrientationChange";
} from "@/stateBox/useCurrentTrain";
import { useDeviceOrientationChange } from "@/stateBox/useDeviceOrientationChange";
import { SheetManager } from "react-native-actions-sheet";
import { useNavigation, useIsFocused } from "@react-navigation/native";
import { useTrainMenu } from "../../stateBox/useTrainMenu";
import { useStationList } from "../../stateBox/useStationList";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
import {
useCustomTrainData,
useOperationLogs,
useTrainDiagram,
} from "@/stateBox/useAllTrainDiagram";
import { useStationList } from "@/stateBox/useStationList";
import { useThemeColors } from "@/lib/theme";
import { useWebViewRemount } from "@/lib/useWebViewRemount";
import { generateMockUpdateScript } from "../../lib/mockApi/webviewXhrInterceptor";
import { generateMockUpdateScript } from "@/lib/mockApi/webviewXhrInterceptor";
import { telemetry } from "@/lib/observability/telemetry";
import { setAppLifecycleWebViewActive } from "@/lib/observability/appLifecycleCrashSentinel";
import {
markStartup,
markStartupOnce,
markStartupState,
} from "@/lib/devStartupPerformance";
import { usePositionsStartupReady } from "@/lib/positionsStartupContext";
import {
generateNativeWebViewDataBootstrapScript,
NATIVE_WEBVIEW_BOOTSTRAP_MAX_AGE_MS,
type NativeWebViewDataBootstrap,
} from "@/lib/webViewInjectjavascript";
const positionsWebViewTelemetry = telemetry.scope("positions.webview");
export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady }) => {
markStartupOnce("positions-webview-render", "positions WebView render");
const initialPositionsUrl = "https://train.jr-shikoku.co.jp/sp.html";
const { currentTrain } = useCurrentTrainData();
const { webview } = useCurrentTrainView();
@@ -37,6 +54,7 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
const { favoriteStation } = useFavoriteStation();
const { isLandscape } = useDeviceOrientationChange();
const { isDark } = useThemeColors();
const { notifyWebViewLoadStarted } = usePositionsStartupReady();
const bgColor = isDark ? "#1c1c1e" : "#ffffff";
const { originalStationList, stationList, getInjectJavascriptAddress } =
useStationList();
@@ -49,6 +67,64 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
mockApiFeatureEnabled,
mockTrainPositions,
} = useTrainMenu();
const { allCustomTrainData, dataReadyAt: customTrainDataReadyAt } =
useCustomTrainData();
const { todayOperation, dataReadyAt: operationLogsReadyAt } =
useOperationLogs();
const {
allTrainDiagram,
dataReadyAt: trainDiagramChangedAt,
getDataVerifiedAt: getTrainDiagramVerifiedAt,
} = useTrainDiagram();
// 304で内容未変更を確認した時刻も鮮度として扱う。
const trainDiagramReadyAt =
getTrainDiagramVerifiedAt() ?? trainDiagramChangedAt;
// Capture one native snapshot for this WebView instance. Keeping the large
// train payload in a ref avoids serializing it again on each 30-second poll.
const nativeBootstrapScriptRef = useRef<string | null>(null);
if (nativeBootstrapScriptRef.current === null) {
const now = Date.now();
const isFresh = (readyAt: number | null): readyAt is number =>
typeof readyAt === "number" &&
now >= readyAt &&
now - readyAt <= NATIVE_WEBVIEW_BOOTSTRAP_MAX_AGE_MS;
const nativeBootstrap: NativeWebViewDataBootstrap = { version: 1 };
let hasFreshData = false;
if (isFresh(customTrainDataReadyAt)) {
nativeBootstrap.trainDataList = {
data: allCustomTrainData,
updatedAt: customTrainDataReadyAt,
};
hasFreshData = true;
}
if (isFresh(operationLogsReadyAt)) {
nativeBootstrap.operationList = {
data: todayOperation,
updatedAt: operationLogsReadyAt,
};
hasFreshData = true;
}
if (isFresh(trainDiagramReadyAt)) {
nativeBootstrap.trainDiagramData2 = {
data: allTrainDiagram,
updatedAt: trainDiagramReadyAt,
};
hasFreshData = true;
}
nativeBootstrapScriptRef.current = generateNativeWebViewDataBootstrapScript(
hasFreshData ? nativeBootstrap : null,
);
}
const webViewBeforeContentLoaded = useMemo(
() =>
[nativeBootstrapScriptRef.current, injectJavascriptBeforeContentLoaded]
.filter(Boolean)
.join("\n"),
[injectJavascriptBeforeContentLoaded],
);
const addWebViewBreadcrumb = (
message: string,
data?: Record<string, string | number | boolean | null | undefined>
@@ -71,22 +147,13 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
reason,
...(data ?? {}),
});
telemetry.message("positions.webview.remount_requested", {
level: "warning",
tags: {
area: "positions_webview",
platform: Platform.OS,
reason,
watchdogMode,
},
contexts: {
positions_webview_remount: {
focused: isFocused,
landscape: isLandscape,
...(data ?? {}),
},
},
fingerprint: ["positions_webview_remount", reason],
telemetry.log("warn", "positions.webview.remount_requested", {
area: "positions_webview",
reason,
watchdogMode,
focused: isFocused,
landscape: isLandscape,
...(data ?? {}),
});
},
onWatchdog: (reason, data) => {
@@ -95,28 +162,20 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
watchdogMode,
...(data ?? {}),
});
telemetry.message("positions.webview.watchdog_detected", {
level: "warning",
tags: {
area: "positions_webview",
platform: Platform.OS,
reason,
watchdogMode,
},
contexts: {
positions_webview_watchdog: {
focused: isFocused,
landscape: isLandscape,
...(data ?? {}),
},
},
fingerprint: ["positions_webview_watchdog", reason],
telemetry.log("warn", "positions.webview.watchdog_detected", {
area: "positions_webview",
reason,
watchdogMode,
focused: isFocused,
landscape: isLandscape,
...(data ?? {}),
});
},
});
const lastRemountKeyRef = useRef<typeof remountKey | null>(null);
useEffect(() => {
markStartupOnce("positions-webview-mounted", "positions WebView mounted");
setAppLifecycleWebViewActive("positions_main", true);
return () => setAppLifecycleWebViewActive("positions_main", false);
}, []);
@@ -145,6 +204,9 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
}, [mockApiFeatureEnabled, remount]);
useEffect(() => {
markStartupState("positions-webview-focus", "positions WebView focus state", {
focused: isFocused,
});
focusedRef.current = isFocused;
addWebViewBreadcrumb(isFocused ? "webview focused" : "webview blurred", {
landscape: isLandscape,
@@ -225,9 +287,8 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
addWebViewBreadcrumb("webview navigation", { url });
switch (true) {
case url.includes("https://train.jr-shikoku.co.jp/usage.htm"):
if (Platform.OS === "android") navigate("howto", { info: url });
if (Platform.OS === "android") navigate("generalWebView", { uri: url });
webview?.current.goBack();
//Actions.howto();
break;
case url.includes("https://train.jr-shikoku.co.jp/train.html"):
//Actions.trainbase({info: url});
@@ -332,6 +393,10 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
};
const onLoadStart = () => {
notifyWebViewLoadStarted();
markStartup("positions WebView loadStart", {
focused: focusedRef.current,
});
pingHandlers.onLoadStart?.();
addWebViewBreadcrumb("webview loadStart", {
focused: focusedRef.current,
@@ -340,6 +405,9 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
};
const onLoadEnd = () => {
markStartup("positions WebView loadEnd", {
focused: focusedRef.current,
});
pingHandlers.onLoadEnd?.();
addWebViewBreadcrumb("webview loadEnd", {
focused: focusedRef.current,
@@ -349,6 +417,11 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
});
if (!initialLoadReadyNotifiedRef.current) {
initialLoadReadyNotifiedRef.current = true;
markStartupOnce(
"positions-webview-initial-ready",
"positions WebView initial load ready",
{ focused: focusedRef.current },
);
addWebViewBreadcrumb("webview initial load ready", {
focused: focusedRef.current,
});
@@ -368,6 +441,10 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
addWebViewBreadcrumb("webview initial inject scheduled", {
focused: focusedRef.current,
});
markStartup("positions WebView initial inject scheduled", {
focused: focusedRef.current,
favoriteCount: favoriteStation.length,
});
loadEndTimeoutRef.current = setTimeout(() => {
addWebViewBreadcrumb(
focusedRef.current
@@ -377,6 +454,10 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
focused: focusedRef.current,
}
);
markStartup("positions WebView initial inject run", {
focused: focusedRef.current,
favoriteCount: favoriteStation.length,
});
webview?.current?.injectJavaScript(string);
pendingInitialInjectRef.current = null;
initialInjectDoneRef.current = true;
@@ -426,7 +507,7 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady
onRenderProcessGone={handleRenderProcessGone}
onContentProcessDidTerminate={handleContentProcessDidTerminate}
{...{ onMessage, onNavigationStateChange, onLoadEnd }}
injectedJavaScriptBeforeContentLoaded={injectJavascriptBeforeContentLoaded}
injectedJavaScriptBeforeContentLoaded={webViewBeforeContentLoaded}
injectedJavaScript={injectJavascript}
/>
);
+23 -21
View File
@@ -156,6 +156,7 @@ export const DynamicHeaderScrollView: React.FC<DynamicHeaderScrollViewProps> = (
<View {...containerProps}>
<View style={{ zIndex: 1 }}>
<Animated.View
pointerEvents={headerVisible ? "auto" : "none"}
style={[
styles.header,
headerVisible ? shotHeaderStyle.on : shotHeaderStyle.off,
@@ -164,6 +165,7 @@ export const DynamicHeaderScrollView: React.FC<DynamicHeaderScrollViewProps> = (
{viewHeader ? <></> : shortHeader}
</Animated.View>
<Animated.View
pointerEvents={headerVisible ? "none" : "auto"}
style={[
styles.header,
headerVisible ? longHeaderStyle.off : longHeaderStyle.on,
@@ -175,27 +177,27 @@ export const DynamicHeaderScrollView: React.FC<DynamicHeaderScrollViewProps> = (
{topStickyContent}
</Animated.View>
</View>
<ScrollView
ref={scrollRef}
nestedScrollEnabled
bounces={false}
style={{ zIndex: 0 }}
stickyHeaderIndices={[1]}
scrollEventThrottle={1}
onScroll={onScroll}
>
<View style={{ height: Scroll_Distance, flexDirection: "column" }} />
{topStickyContent && (
<View
style={{
paddingTop: Min_Header_Height + verticalScale(40),
flexDirection: "column",
}}
//index={1}
/>
)}
{children}
</ScrollView>
<ScrollView
ref={scrollRef}
nestedScrollEnabled
bounces={false}
style={{ zIndex: 0 }}
stickyHeaderIndices={[1]}
scrollEventThrottle={1}
onScroll={onScroll}
>
<View style={{ height: Scroll_Distance, flexDirection: "column" }} />
{topStickyContent && (
<View
style={{
paddingTop: Min_Header_Height + verticalScale(40),
flexDirection: "column",
}}
//index={1}
/>
)}
{children}
</ScrollView>
</View>
);
};
+6 -2
View File
@@ -34,6 +34,7 @@ import type { NavigateFunction } from "@/types";
import { telemetry } from "@/lib/observability/telemetry";
import { FavoriteEmptyState } from "./FavoriteEmptyState";
import { StationListLoadingState } from "./StationListLoadingState";
import { markStartupOnce } from "@/lib/devStartupPerformance";
const menuCarouselTelemetry = telemetry.scope("menu.carousel");
@@ -78,6 +79,11 @@ export const CarouselBox = ({
isPositionLoading: boolean;
}) => {
const isAndroid = Platform.OS === "android";
useEffect(() => {
markStartupOnce("menu-carousel-mounted", "menu carousel mounted", {
stationGroupCount: listUpStation.length,
});
}, [listUpStation.length]);
const sortControlsEntering = isAndroid ? undefined : FadeIn.duration(200);
const sortControlsExiting = isAndroid ? undefined : FadeOut.duration(150);
const carouselRef = useRef<ICarouselInstance>(null);
@@ -253,11 +259,9 @@ export const CarouselBox = ({
currentStation: listUpStation[listIndex],
navigate,
goTo: "menu",
//@ts-ignore
useShow: () => SheetManager.show("StationDetailView", { payload }),
onExit: () => SheetManager.hide("StationDetailView"),
};
//@ts-ignore
SheetManager.show("StationDetailView", { payload });
};
+12 -12
View File
@@ -1,4 +1,4 @@
import { Linking, Text, View, TouchableOpacity } from "react-native";
import { Text, View, TouchableOpacity } from "react-native";
import {
Foundation,
FontAwesome,
@@ -17,6 +17,7 @@ import { useNotification } from "@/stateBox/useNotifications";
import { useThemeColors } from "@/lib/theme";
import { useResponsive } from "@/lib/responsive";
import { isAvailable as isFelicaAvailable } from "@/modules/expo-felica-reader/src";
import { openExternalUrl } from "@/lib/openExternalUrl";
export const FixedContentBottom = (props) => {
const { expoPushToken } = useNotification();
@@ -33,7 +34,7 @@ export const FixedContentBottom = (props) => {
icon={<Foundation name="ticket" color="white" size={moderateScale(50)} />}
flex={1}
onPressButton={() =>
Linking.openURL("https://www.jr-eki.com/ticket/brand")
openExternalUrl("https://www.jr-eki.com/ticket/brand")
}
>
トクトク切符
@@ -43,7 +44,7 @@ export const FixedContentBottom = (props) => {
icon={<FontAwesome name="first-order" color="white" size={moderateScale(50)} />}
flex={1}
onPressButton={() =>
Linking.openURL(
openExternalUrl(
"https://www.jr-shikoku.co.jp/01_trainbus/event_train/sp/",
)
}
@@ -55,7 +56,7 @@ export const FixedContentBottom = (props) => {
icon={<Ionicons name="flag" color="white" size={moderateScale(50)} />}
flex={1}
onPressButton={() =>
Linking.openURL("https://www.jr-eki.com/tour/brand")
openExternalUrl("https://www.jr-eki.com/tour/brand")
}
>
旅行ツアー
@@ -65,7 +66,7 @@ export const FixedContentBottom = (props) => {
backgroundColor="#ed86b5"
flex={1}
onPressButton={() =>
Linking.openURL("https://www.jr-eki.com/smart-eki/index.html")
openExternalUrl("https://www.jr-eki.com/smart-eki/index.html")
}
>
<Text style={{ color: "white", fontWeight: "bold", fontSize: fontScale(20) }}>
@@ -111,7 +112,7 @@ export const FixedContentBottom = (props) => {
backgroundColor={fixed.primary}
flex={1}
onPressButton={() =>
Linking.openURL("https://www.jr-shikoku.co.jp/teiki/")
openExternalUrl("https://www.jr-shikoku.co.jp/teiki/")
}
>
<Text style={{ color: fixed.textOnPrimary, fontWeight: "bold", fontSize: fontScale(20) }}>
@@ -125,7 +126,7 @@ export const FixedContentBottom = (props) => {
backgroundColor={fixed.primary}
flex={1}
onPressButton={() =>
Linking.openURL("https://www.jr-shikoku.co.jp/04_company/group/sp/")
openExternalUrl("https://www.jr-shikoku.co.jp/04_company/group/sp/")
}
>
<Text style={{ color: fixed.textOnPrimary, fontWeight: "bold", fontSize: fontScale(20) }}>
@@ -146,7 +147,7 @@ export const FixedContentBottom = (props) => {
margin: 2,
alignItems: "center",
}}
onPress={() => Linking.openURL("https://www.jr-odekake.net/smt/")}
onPress={() => openExternalUrl("https://www.jr-odekake.net/smt/")}
>
<Text style={{ color: "white", fontWeight: "bold", fontSize: fontScale(20) }}>
時刻・運賃計算
@@ -294,7 +295,7 @@ export const FixedContentBottom = (props) => {
backgroundColor="rgb(88, 101, 242)"
flex={1}
onPressButton={() =>
Linking.openURL(
openExternalUrl(
"https://twitter.com/Xprocess_main/status/1955242437817012300",
)
}
@@ -310,9 +311,8 @@ export const FixedContentBottom = (props) => {
backgroundColor="linear-gradient(120deg, rgba(247,135,54,0.208) 0%, rgba(54,125,247,0.208) 100%)"
flex={1}
onPressButton={() =>
props.navigate("howto", {
info: "https://xprocess.haruk.in/tags/JR%E5%9B%9B%E5%9B%BD%E5%88%97%E8%BB%8A%E4%BD%8D%E7%BD%AE%E6%83%85%E5%A0%B1%E3%82%A2%E3%83%97%E3%83%AA/",
goTo: "menu",
props.navigate("generalWebView", {
uri: "https://xprocess.haruk.in/tags/JR%E5%9B%9B%E5%9B%BD%E5%88%97%E8%BB%8A%E4%BD%8D%E7%BD%AE%E6%83%85%E5%A0%B1%E3%82%A2%E3%83%97%E3%83%AA/",
})
}
>
+6 -1
View File
@@ -1,4 +1,4 @@
import React from "react";
import React, { useEffect } from "react";
import {
View,
Text,
@@ -19,6 +19,11 @@ export const JRSTraInfoBox = () => {
useTrainDelayData();
const { colors, fixed } = useThemeColors();
const { fontScale, moderateScale, verticalScale } = useResponsive();
useEffect(() => {
void refreshDelayData();
}, [refreshDelayData]);
const styles: { [key: string]: StyleProp<ViewStyle> } = {
touch: {
backgroundColor: fixed.primary,
+5
View File
@@ -6,6 +6,7 @@ import { useThemeColors } from "@/lib/theme";
import type { StationProps } from "@/lib/CommonTypes";
import { LED_vision } from "@/components/発車時刻表/LED_vidion";
import { LEDFrame } from "@/components/発車時刻表/LED_Vision_Component/LEDFrame";
import { markStartupOnce } from "@/lib/devStartupPerformance";
type MenuLEDProps = {
station: StationProps[];
@@ -39,6 +40,10 @@ const MenuLEDComponent = ({ station }: MenuLEDProps) => {
const activeStation =
preparedLED.key === stationKey ? preparedLED.station : null;
useEffect(() => {
markStartupOnce("menu-led-mounted", "menu LED mounted");
}, []);
useEffect(() => {
if (preparedLED.key === stationKey) return;
+1 -1
View File
@@ -56,7 +56,7 @@ export const NewsReleaseInfoBox: FC<navigateProps> = ({ navigate }) => {
};
const onPressItem = (d: newsDataType) => {
navigate("howto", { info: getPDFViewURL(d.url), goTo: "menu" });
navigate("generalWebView", { uri: getPDFViewURL(d.url) });
SheetManager.hide("NewsReleaseInfo");
};
+2 -3
View File
@@ -29,9 +29,8 @@ export const SpecialTrainInfoBox: FC<props> = ({ navigate }) => {
}, []);
const onPressItem: (d: specialDataType) => void = (d) => {
navigate("howto", {
info: getPDFViewURL("https://www.jr-shikoku.co.jp" + d.address),
goTo: "menu",
navigate("generalWebView", {
uri: getPDFViewURL("https://www.jr-shikoku.co.jp" + d.address),
});
SheetManager.hide("SpecialTrainInfo");
};
+4 -3
View File
@@ -1,7 +1,8 @@
import { View, TouchableOpacity, Linking, Platform, Image, useWindowDimensions } from "react-native";
import { View, TouchableOpacity, Platform, Image, useWindowDimensions } from "react-native";
import { useThemeColors } from "@/lib/theme";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useResponsive } from "@/lib/responsive";
import { openExternalUrl } from "@/lib/openExternalUrl";
export const TitleBar = () => {
@@ -22,9 +23,9 @@ export const TitleBar = () => {
}}
>
<TouchableOpacity
onPress={() => Linking.openURL("https://www.jr-shikoku.co.jp")}
onPress={() => openExternalUrl("https://www.jr-shikoku.co.jp")}
>
<Image source={require("../../assets/Header.png")} style={{ width: width, resizeMode: "contain", backgroundColor: colors.background, height: verticalScale(80) }} />
<Image source={require("@/assets/Header.png")} style={{ width: width, resizeMode: "contain", backgroundColor: colors.background, height: verticalScale(80) }} />
</TouchableOpacity>
</View>
);
+5 -4
View File
@@ -1,7 +1,8 @@
import React from "react";
import { Linking, View } from "react-native";
import { View } from "react-native";
import { UsefulBox } from "@/components/TrainMenu/UsefulBox";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { openExternalUrl } from "@/lib/openExternalUrl";
export const TopMenuButton = () => {
const buttonList:{
backgroundColor: string;
@@ -13,14 +14,14 @@ export const TopMenuButton = () => {
backgroundColor: "#F89038",
icon: "train-car",
onPress: () =>
Linking.openURL("https://www.jr-shikoku.co.jp/01_trainbus/sp/"),
openExternalUrl("https://www.jr-shikoku.co.jp/01_trainbus/sp/"),
title: "駅・鉄道情報",
},
{
backgroundColor: "#EA4752",
icon: "google-spreadsheet",
onPress: () =>
Linking.openURL(
openExternalUrl(
"https://www.jr-shikoku.co.jp/01_trainbus/jikoku/sp/#mainprice-box"
),
title: "運賃表",
@@ -28,7 +29,7 @@ export const TopMenuButton = () => {
{
backgroundColor: "#91C31F",
icon: "clipboard-list-outline",
onPress: () => Linking.openURL("https://www.jr-shikoku.co.jp/e5489/"),
onPress: () => openExternalUrl("https://www.jr-shikoku.co.jp/e5489/"),
title: "予約",
},
];
@@ -0,0 +1,519 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
ActivityIndicator,
RefreshControl,
ScrollView,
Text,
TouchableOpacity,
View,
} from "react-native";
import { useFocusEffect, useNavigation } from "@react-navigation/native";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import dayjs from "dayjs";
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
import { AS } from "@/storageControl";
import { STORAGE_KEYS } from "@/constants";
import { useThemeColors } from "@/lib/theme";
import { connectionStatusClient } from "@/lib/connectionStatusClient";
import {
getInitialServiceHealthResults,
isDeviceOffline,
runConnectionStatusChecks,
type ConnectionStatusFeatureFlags,
type ServiceHealthResult,
type ServiceHealthStatus,
} from "@/lib/connectionStatus";
import {
DEFAULT_JR_DATA_SYSTEM_ENV,
normalizeJrDataSystemEnvironment,
} from "@/lib/jrDataSystemEnvironment";
const STATUS_TEXT: Record<ServiceHealthStatus, { label: string }> = {
checking: { label: "確認中" },
ok: { label: "正常" },
degraded: { label: "更新遅延" },
error: { label: "接続できません" },
unknown: { label: "判定不能" },
};
const formatTime = (value: string | null | undefined): string => {
if (!value || !Number.isFinite(Date.parse(value))) return "—";
return dayjs(value).format("HH:mm:ss");
};
const formatAge = (value: number | null | undefined): string => {
if (value === null || value === undefined || !Number.isFinite(value)) return "—";
if (value < 60) return Math.max(0, Math.floor(value)) + "秒";
return Math.floor(value / 60) + "分" + Math.floor(value % 60) + "秒";
};
const readStoredBoolean = async (key: string): Promise<boolean> => {
try {
const value = await AS.getItem(key);
return value === true || value === "true";
} catch {
return false;
}
};
const loadConnectionStatusFeatureFlags = async (): Promise<ConnectionStatusFeatureFlags> => {
const [voicepeakEnabled, unyohubEnabled, elesiteEnabled] = await Promise.all([
readStoredBoolean(STORAGE_KEYS.VOICEPEAK_ENABLED),
readStoredBoolean(STORAGE_KEYS.USE_UNYOHUB),
readStoredBoolean(STORAGE_KEYS.USE_ELESITE),
]);
return { voicepeakEnabled, unyohubEnabled, elesiteEnabled };
};
const getStatusColor = (
status: ServiceHealthStatus,
colors: ReturnType<typeof useThemeColors>["colors"],
fixed: ReturnType<typeof useThemeColors>["fixed"],
) => {
switch (status) {
case "ok":
return fixed.buttonGreen;
case "degraded":
return colors.textCaution;
case "error":
return colors.textError;
case "unknown":
return colors.textTertiary;
case "checking":
default:
return colors.textAccent;
}
};
export const ConnectionStatusSettings = () => {
const navigation = useNavigation();
const { colors, fixed } = useThemeColors();
const [services, setServices] = useState<ServiceHealthResult[]>(() =>
getInitialServiceHealthResults(DEFAULT_JR_DATA_SYSTEM_ENV),
);
const [isChecking, setIsChecking] = useState(false);
const [lastCheckedAt, setLastCheckedAt] = useState<string | null>(null);
const [isOffline, setIsOffline] = useState(false);
const [expandedServiceId, setExpandedServiceId] = useState<string | null>(null);
const isMountedRef = useRef(true);
const isCheckingRef = useRef(false);
const runIdRef = useRef(0);
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
return () => {
isMountedRef.current = false;
abortControllerRef.current?.abort();
};
}, []);
const runChecks = useCallback(async () => {
if (isCheckingRef.current) return;
isCheckingRef.current = true;
const runId = ++runIdRef.current;
const controller = new AbortController();
abortControllerRef.current = controller;
const offline = isDeviceOffline();
if (isMountedRef.current) {
setIsChecking(true);
setIsOffline(offline);
setLastCheckedAt(null);
setExpandedServiceId(null);
setServices(getInitialServiceHealthResults(DEFAULT_JR_DATA_SYSTEM_ENV));
}
try {
let nextEnvironment = DEFAULT_JR_DATA_SYSTEM_ENV;
try {
nextEnvironment = normalizeJrDataSystemEnvironment(
await AS.getItem(STORAGE_KEYS.JR_DATA_SYSTEM_ENV),
);
} catch {
// The default release environment is the same fallback used elsewhere.
}
const featureFlags = await loadConnectionStatusFeatureFlags();
if (!isMountedRef.current || runId !== runIdRef.current) return;
setServices(getInitialServiceHealthResults(nextEnvironment, featureFlags));
await runConnectionStatusChecks({
client: connectionStatusClient,
environment: nextEnvironment,
featureFlags,
signal: controller.signal,
isOffline: () => offline,
onServiceResult: (result) => {
if (!isMountedRef.current || runId !== runIdRef.current) return;
setServices((current) =>
current.map((service) => (service.id === result.id ? result : service)),
);
},
});
if (
isMountedRef.current &&
runId === runIdRef.current &&
!controller.signal.aborted
) {
setLastCheckedAt(new Date().toISOString());
}
} finally {
if (runId === runIdRef.current) {
isCheckingRef.current = false;
abortControllerRef.current = null;
if (isMountedRef.current) setIsChecking(false);
}
}
}, []);
useFocusEffect(
useCallback(() => {
void runChecks();
}, [runChecks]),
);
const hasBlockingProblem = services.some(
(service) =>
service.enabled !== false &&
service.checks.some(
(check) =>
check.role !== "optional" &&
["degraded", "error", "unknown"].includes(check.status),
),
);
const hasOptionalProblem = services.some(
(service) =>
service.enabled !== false &&
service.checks.some(
(check) =>
check.role === "optional" &&
["degraded", "error", "unknown"].includes(check.status),
),
);
const hasProblem = hasBlockingProblem || hasOptionalProblem;
const hasChecking = services.some(
(service) => service.enabled !== false && service.status === "checking",
);
const summary = hasChecking
? "サービスを確認しています"
: hasBlockingProblem
? "一部のサービスで問題が発生しています"
: hasOptionalProblem
? "一部の任意サービスを確認できません"
: "すべてのサービスは正常です";
return (
<View style={{ flex: 1, backgroundColor: fixed.primary }}>
<SheetHeaderItem
title="接続ステータス"
LeftItem={{ title: "< 設定", onPress: () => navigation.goBack() }}
/>
<ScrollView
style={{ flex: 1, backgroundColor: colors.backgroundSecondary }}
contentContainerStyle={{ paddingBottom: 24 }}
refreshControl={
<RefreshControl
refreshing={isChecking}
onRefresh={() => void runChecks()}
/>
}
>
<View
style={{
margin: 12,
padding: 16,
borderRadius: 10,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.borderCard,
}}
>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<MaterialCommunityIcons
name={
hasChecking && isChecking
? "loading"
: hasProblem
? "alert-circle-outline"
: "check-circle-outline"
}
size={28}
color={
hasChecking && isChecking
? colors.textAccent
: hasProblem
? colors.textCaution
: fixed.buttonGreen
}
/>
<Text
style={{
flex: 1,
marginLeft: 10,
color: colors.text,
fontSize: 17,
fontWeight: "700",
}}
>
{summary}
</Text>
</View>
{isOffline ? (
<View
style={{
marginTop: 12,
padding: 10,
borderRadius: 6,
backgroundColor: colors.backgroundTertiary,
}}
>
<Text style={{ color: colors.textError, fontWeight: "600" }}>
インターネットに接続されていません
</Text>
</View>
) : null}
<Text style={{ marginTop: 12, color: colors.textSecondary, fontSize: 13 }}>
最終確認: {lastCheckedAt ? formatTime(lastCheckedAt) : "—"}
</Text>
<TouchableOpacity
accessibilityRole="button"
accessibilityLabel="接続ステータスを再確認"
disabled={isChecking}
onPress={() => void runChecks()}
style={{
marginTop: 12,
minHeight: 42,
borderRadius: 7,
backgroundColor: isChecking
? colors.backgroundTertiary
: fixed.primary,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 14,
}}
>
{isChecking ? (
<ActivityIndicator color={fixed.textOnPrimary} size="small" />
) : (
<MaterialCommunityIcons
name="refresh"
size={18}
color={fixed.textOnPrimary}
/>
)}
<Text
style={{
marginLeft: 6,
color: isChecking ? colors.textTertiary : fixed.textOnPrimary,
fontWeight: "700",
}}
>
{isChecking ? "確認中…" : "再確認"}
</Text>
</TouchableOpacity>
</View>
<Text
style={{
marginHorizontal: 16,
marginTop: 4,
marginBottom: 6,
color: colors.textTertiary,
fontSize: 13,
}}
>
アプリが利用する公開データを、画面を開いた時と再確認時だけ確認します。
</Text>
<View
style={{
marginHorizontal: 12,
borderRadius: 10,
overflow: "hidden",
backgroundColor: colors.surface,
}}
>
{services.map((service) => (
<ServiceStatusRow
key={service.id}
service={service}
expanded={expandedServiceId === service.id}
onPress={() =>
setExpandedServiceId((current) =>
current === service.id ? null : service.id,
)
}
colors={colors}
fixed={fixed}
/>
))}
</View>
<Text
style={{
marginHorizontal: 16,
marginTop: 12,
color: colors.textTertiary,
fontSize: 12,
lineHeight: 18,
}}
>
判定不能は、接続先の応答形式を確認できない状態です。列車の遅延や運休そのものは、API障害として判定しません。
</Text>
</ScrollView>
</View>
);
};
const ServiceStatusRow = ({
service,
expanded,
onPress,
colors,
fixed,
}: {
service: ServiceHealthResult;
expanded: boolean;
onPress: () => void;
colors: ReturnType<typeof useThemeColors>["colors"];
fixed: ReturnType<typeof useThemeColors>["fixed"];
}) => {
const status = STATUS_TEXT[service.status];
const isDisabled = service.enabled === false;
const displayStatus = isDisabled ? { label: "未使用" } : status;
const statusColor = isDisabled
? colors.textTertiary
: getStatusColor(service.status, colors, fixed);
const latestSuccess = service.checks.find((check) => check.lastSuccessAt);
const latestAge = service.checks.reduce<number | null>((worst, check) => {
if (check.ageSeconds === null || check.ageSeconds === undefined) return worst;
return worst === null ? check.ageSeconds : Math.max(worst, check.ageSeconds);
}, null);
const maxLatency = service.checks.reduce<number | null>((max, check) => {
if (check.latencyMs === undefined) return max;
return max === null ? check.latencyMs : Math.max(max, check.latencyMs);
}, null);
const detailMessage =
service.message ??
service.checks.find(
(check) => check.message && check.status !== "ok",
)?.message ??
"正常";
return (
<TouchableOpacity
accessibilityRole="button"
accessibilityLabel={service.label + ": " + displayStatus.label}
onPress={onPress}
style={{
paddingHorizontal: 14,
paddingVertical: 13,
borderBottomWidth: 1,
borderBottomColor: colors.borderSecondary,
}}
>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<MaterialCommunityIcons
name={service.icon as any}
size={22}
color={colors.iconSecondary}
/>
<Text
style={{
flex: 1,
marginLeft: 10,
color: colors.text,
fontSize: 16,
fontWeight: "600",
}}
>
{service.label}
</Text>
<View style={{ flexDirection: "row", alignItems: "center" }}>
{!isDisabled && service.status === "checking" ? (
<ActivityIndicator
size="small"
color={statusColor}
style={{ marginRight: 6 }}
/>
) : (
<View
style={{
width: 9,
height: 9,
borderRadius: 5,
backgroundColor: statusColor,
marginRight: 6,
}}
/>
)}
<Text style={{ color: statusColor, fontWeight: "700" }}>
{displayStatus.label}
</Text>
</View>
</View>
{expanded ? (
<View
style={{
marginTop: 12,
marginLeft: 32,
paddingTop: 10,
borderTopWidth: 1,
borderTopColor: colors.borderSecondary,
}}
>
<DetailLine
label="状態"
value={displayStatus.label}
valueColor={statusColor}
colors={colors}
/>
<DetailLine
label="最終データ更新"
value={formatTime(latestSuccess?.lastSuccessAt)}
colors={colors}
/>
<DetailLine
label="データ経過時間"
value={formatAge(latestAge)}
colors={colors}
/>
<DetailLine
label="API応答時間"
value={maxLatency === null ? "—" : maxLatency + " ms"}
colors={colors}
/>
<DetailLine
label="確認時刻"
value={formatTime(service.checks[0]?.checkedAt)}
colors={colors}
/>
<Text style={{ marginTop: 6, color: colors.textSecondary, fontSize: 12 }}>
{detailMessage}
</Text>
</View>
) : null}
</TouchableOpacity>
);
};
const DetailLine = ({
label,
value,
valueColor,
colors,
}: {
label: string;
value: string;
valueColor?: string;
colors: ReturnType<typeof useThemeColors>["colors"];
}) => (
<View style={{ flexDirection: "row", marginBottom: 5 }}>
<Text style={{ width: 125, color: colors.textTertiary, fontSize: 12 }}>
{label}
</Text>
<Text style={{ flex: 1, color: valueColor ?? colors.textSecondary, fontSize: 12 }}>
{value}
</Text>
</View>
);
+13 -9
View File
@@ -6,13 +6,12 @@ import {
StyleSheet,
Image,
TouchableOpacity,
Linking,
} from "react-native";
import { Switch } from "@rneui/themed";
import { useNavigation } from "@react-navigation/native";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
import { AS } from "../../storageControl";
import { AS } from "@/storageControl";
import { STORAGE_KEYS } from "@/constants";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
import { useThemeColors } from "@/lib/theme";
@@ -27,6 +26,7 @@ import {
normalizeJrDataSystemEnvironment,
resolveJrDataSystemEnvironment,
} from "@/lib/jrDataSystemEnvironment";
import { openExternalUrl } from "@/lib/openExternalUrl";
const HUB_LOGO_PNG = require("@/assets/relationLogo/unyohub_logo.webp");
const ELESITE_LOGO_PNG = require("@/assets/relationLogo/elesite_logo.jpg");
@@ -206,7 +206,7 @@ const DataSourceAccordionCard: React.FC<DataSourceAccordionCardProps> = ({
styles.bodyLink,
{ borderTopColor: colors.borderSecondary },
]}
onPress={() => Linking.openURL(linkUrl)}
onPress={() => openExternalUrl(linkUrl)}
activeOpacity={0.7}
>
<MaterialCommunityIcons
@@ -298,12 +298,16 @@ export const DataSourceSettings = () => {
};
useEffect(() => {
AS.getItem(STORAGE_KEYS.USE_UNYOHUB).then((value) => {
setUseUnyohub(value === true || value === "true");
});
AS.getItem(STORAGE_KEYS.USE_ELESITE).then((value) => {
setUseElesite(value === true || value === "true");
});
AS.getItem(STORAGE_KEYS.USE_UNYOHUB)
.then((value) => {
setUseUnyohub(value === true || value === "true");
})
.catch(() => setUseUnyohub(false));
AS.getItem(STORAGE_KEYS.USE_ELESITE)
.then((value) => {
setUseElesite(value === true || value === "true");
})
.catch(() => setUseElesite(false));
AS.getItem(STORAGE_KEYS.JR_DATA_SYSTEM_ENV)
.then((value) => {
const env = normalizeJrDataSystemEnvironment(value);
@@ -1,9 +1,9 @@
import React, { useEffect, useState } from "react";
import Icon from "react-native-vector-icons/Entypo";
import { View, Text, TouchableOpacity, LayoutAnimation } from "react-native";
import lineColorList from "../../../assets/originData/lineColorList";
import lineColorList from "@/assets/originData/lineColorList";
import Ionicons from "react-native-vector-icons/Ionicons";
import { AS } from "../../../storageControl";
import { AS } from "@/storageControl";
import { useThemeColors } from "@/lib/theme";
export const FavoriteSettingsItem = ({ currentStation }) => {
+1 -1
View File
@@ -3,7 +3,7 @@ import { View, Text, StyleSheet } from "react-native";
import Animated, { useAnimatedRef } from "react-native-reanimated";
import { useNavigation } from "@react-navigation/native";
import Sortable from "react-native-sortables";
import { useFavoriteStation } from "../../stateBox/useFavoriteStation";
import { useFavoriteStation } from "@/stateBox/useFavoriteStation";
import { FavoriteSettingsItem } from "./FavoliteSettings/FavoiliteSettingsItem";
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
import { getFavoriteStationKey } from "@/lib/favoriteStationUtils";
+6 -6
View File
@@ -15,21 +15,21 @@ import { BigButton } from "../atom/BigButton";
import { SheetHeaderItem } from "../atom/SheetHeaderItem";
import { useThemeColors } from "@/lib/theme";
import dayjs from "dayjs";
import * as ExpoFelicaReader from "../../modules/expo-felica-reader/src";
import * as ExpoFelicaReader from "@/modules/expo-felica-reader/src";
import { saveWidgetData } from "@/modules/expo-felica-reader/src";
import type { FelicaCardInfo, FelicaHistoryEntry } from "../../modules/expo-felica-reader/src";
import { AS } from "../../storageControl";
import type { FelicaCardInfo, FelicaHistoryEntry } from "@/modules/expo-felica-reader/src";
import { AS } from "@/storageControl";
// felicaStationMap は巨大 (5900行) なため、遅延ロード
let _lookupFelicaStation: typeof import("../../lib/felicaStationMap").lookupFelicaStation | null = null;
let _lookupFelicaStation: typeof import("@/lib/felicaStationMap").lookupFelicaStation | null = null;
async function getLookupFelicaStation() {
if (!_lookupFelicaStation) {
const mod = await import("../../lib/felicaStationMap");
const mod = await import("@/lib/felicaStationMap");
_lookupFelicaStation = mod.lookupFelicaStation;
}
return _lookupFelicaStation;
}
import { STORAGE_KEYS } from "../../constants";
import { STORAGE_KEYS } from "@/constants";
import { requestWidgetUpdate } from "react-native-android-widget";
import {
FelicaQuickAccessWidget,
+4 -4
View File
@@ -13,10 +13,10 @@ import LottieView from "lottie-react-native";
import { useNavigation } from "@react-navigation/native";
import { ListItem } from "@rneui/themed";
import * as Updates from "expo-updates";
import { AS } from "../../storageControl";
import { AS } from "@/storageControl";
import { STORAGE_KEYS } from "@/constants";
import icons, { series as defaultSeries } from "../../assets/icons/icons";
import hubIcons, { series as hubSeries } from "../../assets/unyoHubIcons/icons";
import icons, { series as defaultSeries } from "@/assets/icons/icons";
import hubIcons, { series as hubSeries } from "@/assets/unyoHubIcons/icons";
import { setAlternateAppIcon, getAppIconName } from "expo-alternate-app-icons";
import { SheetHeaderItem } from "../atom/SheetHeaderItem";
import { useThemeColors } from "@/lib/theme";
@@ -362,7 +362,7 @@ export const LauncherIconSettings = () => {
{!ready ? (
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: colors.background }}>
<LottieView
source={require("../../assets/51690-loading-diamonds.json")}
source={require("@/assets/51690-loading-diamonds.json")}
autoPlay
loop
style={{ width: 120, height: 120 }}
+14 -14
View File
@@ -47,17 +47,17 @@ export const LayoutSettings = ({
bool={iconSetting}
setBool={setIconSetting}
firstItem={{
firstImage: require("../../assets/configuration/icon_default.jpg"),
firstImage: require("@/assets/configuration/icon_default.jpg"),
firstText: "本家\n(文字アイコン)",
firstValue: "default",
}}
secondItem={{
secondImage: require("../../assets/configuration/icon_original.jpg"),
secondImage: require("@/assets/configuration/icon_original.jpg"),
secondText: "オリジナル\n(車種アイコン)",
secondValue: "original",
}}
thirdItem={{
thirdImage: require("../../assets/configuration/icon_hub.jpg"),
thirdImage: require("@/assets/configuration/icon_hub.jpg"),
thirdText: "鉄道運用Hub\n(Hubアイコン)",
thirdValue: "hub",
}}
@@ -67,8 +67,8 @@ export const LayoutSettings = ({
str="列車表示"
bool={uiSetting}
setBool={setUiSetting}
falseImage={require("../../assets/configuration/layout_default.jpg")}
trueImage={require("../../assets/configuration/layout_tokyo.jpg")}
falseImage={require("@/assets/configuration/layout_default.jpg")}
trueImage={require("@/assets/configuration/layout_tokyo.jpg")}
falseText={"本家"}
trueText={"オリジナル"}
falseValue="default"
@@ -78,8 +78,8 @@ export const LayoutSettings = ({
str="トップメニュー表示"
bool={mapSwitch}
setBool={setMapSwitch}
falseImage={require("../../assets/configuration/menu_default.jpg")}
trueImage={require("../../assets/configuration/menu_original.jpg")}
falseImage={require("@/assets/configuration/menu_default.jpg")}
trueImage={require("@/assets/configuration/menu_original.jpg")}
falseText={"本家\n(文字&路線リスト)"}
trueText={"オリジナル\n(マップ&お気に入り)"}
/>
@@ -89,8 +89,8 @@ export const LayoutSettings = ({
setBool={setStationMenu}
falseText={"本家\n(文字)"}
trueText={"オリジナル\n(駅名標)"}
falseImage={require("../../assets/configuration/station_default.jpg")}
trueImage={require("../../assets/configuration/station_original.jpg")}
falseImage={require("@/assets/configuration/station_default.jpg")}
trueImage={require("@/assets/configuration/station_original.jpg")}
>
<SimpleSwitch
bool={usePDFView}
@@ -104,25 +104,25 @@ export const LayoutSettings = ({
setBool={setTrainMenu}
falseText={"本家"}
trueText={"オリジナル"}
falseImage={require("../../assets/configuration/train_default.jpg")}
trueImage={require("../../assets/configuration/train_original.jpg")}
falseImage={require("@/assets/configuration/train_default.jpg")}
trueImage={require("@/assets/configuration/train_original.jpg")}
>
<TripleSwitchArea
str={"ヘッダーサイズ"}
bool={headerSize}
setBool={setHeaderSize}
firstItem={{
firstImage: require("../../assets/configuration/train_original_small.jpg"),
firstImage: require("@/assets/configuration/train_original_small.jpg"),
firstText: "小固定",
firstValue: "small",
}}
secondItem={{
secondImage: require("../../assets/configuration/train_original.jpg"),
secondImage: require("@/assets/configuration/train_original.jpg"),
secondText: "既定(可変)",
secondValue: "default",
}}
thirdItem={{
thirdImage: require("../../assets/configuration/train_original.jpg"),
thirdImage: require("@/assets/configuration/train_original.jpg"),
thirdText: "大固定",
thirdValue: "big",
}}
+5 -5
View File
@@ -3,9 +3,9 @@ import { View, Text, TouchableOpacity, ScrollView } from "react-native";
import * as Clipboard from "expo-clipboard";
import { CheckBox } from "@rneui/themed";
import { AS } from "../../storageControl";
import { AS } from "@/storageControl";
import { STORAGE_KEYS } from "@/constants";
import { useNotification } from "../../stateBox/useNotifications";
import { useNotification } from "@/stateBox/useNotifications";
import { useNavigation } from "@react-navigation/native";
import { SheetHeaderItem } from "../atom/SheetHeaderItem";
import { useThemeColors } from "@/lib/theme";
@@ -18,9 +18,9 @@ export const NotificationSettings = () => {
const [informations, setInformations] = useState(false);
const [strangeTrain, setStrangeTrain] = useState(false);
useEffect(() => {
AS.getItem(STORAGE_KEYS.TRA_INFO_EX).then(setTraInfoEX);
AS.getItem(STORAGE_KEYS.INFORMATIONS).then(setInformations);
AS.getItem(STORAGE_KEYS.STRANGE_TRAIN).then(setStrangeTrain);
AS.getItem(STORAGE_KEYS.TRA_INFO_EX).then(setTraInfoEX).catch(() => {});
AS.getItem(STORAGE_KEYS.INFORMATIONS).then(setInformations).catch(() => {});
AS.getItem(STORAGE_KEYS.STRANGE_TRAIN).then(setStrangeTrain).catch(() => {});
}, []);
const setRegister = () => {
+208
View File
@@ -0,0 +1,208 @@
import React, { useCallback, useMemo, useState } from "react";
import {
FlatList,
Platform,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { useNavigation } from "@react-navigation/native";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
import { useThemeColors } from "@/lib/theme";
import {
filterOssLicensePackages,
resolveOssLicenseText,
type OssLicenseData,
type OssLicensePackage,
} from "@/lib/ossLicenses";
import licenseData from "@/assets/licenses/oss-licenses.json";
import { openExternalUrl } from "@/lib/openExternalUrl";
const data = licenseData as OssLicenseData;
const keyOf = (pkg: OssLicensePackage) => `${pkg.name}@${pkg.version}`;
export const OssLicenses = () => {
const navigation = useNavigation();
const { colors, fixed } = useThemeColors();
const [query, setQuery] = useState("");
const [expandedKey, setExpandedKey] = useState<string | null>(null);
const packages = useMemo(
() => filterOssLicensePackages(data.packages, query),
[query],
);
const toggle = useCallback((key: string) => {
setExpandedKey((current) => (current === key ? null : key));
}, []);
const renderItem = useCallback(
({ item }: { item: OssLicensePackage }) => (
<LicenseRow
pkg={item}
expanded={expandedKey === keyOf(item)}
onPress={toggle}
/>
),
[expandedKey, toggle],
);
const header = (
<View>
<Text style={{ color: colors.textSecondary, fontSize: 13, padding: 16, lineHeight: 20 }}>
本アプリは以下のオープンソースソフトウェアを利用しています。項目をタップするとライセンス全文を表示します。
</Text>
<SectionHeader title="改変して同梱しているソフトウェア" />
{data.vendored.map((pkg) => (
<LicenseRow
key={keyOf(pkg)}
pkg={pkg}
expanded={expandedKey === keyOf(pkg)}
onPress={toggle}
/>
))}
<SectionHeader title={`依存ライブラリ(${packages.length}件)`} />
<View style={{ paddingHorizontal: 16, paddingBottom: 8 }}>
<TextInput
value={query}
onChangeText={setQuery}
placeholder="パッケージ名・ライセンス名で検索"
placeholderTextColor={colors.textTertiary}
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
style={{
backgroundColor: colors.backgroundInput,
color: colors.text,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 8,
fontSize: 15,
}}
/>
</View>
</View>
);
return (
<View style={{ flex: 1, backgroundColor: fixed.primary }}>
<SheetHeaderItem
title="オープンソースライセンス"
LeftItem={{ title: "< 設定", onPress: () => navigation.goBack() }}
/>
<FlatList
style={{ flex: 1, backgroundColor: colors.backgroundSecondary }}
data={packages}
keyExtractor={keyOf}
renderItem={renderItem}
extraData={expandedKey}
ListHeaderComponent={header}
ListEmptyComponent={
<Text style={{ color: colors.textTertiary, textAlign: "center", padding: 20 }}>
該当するパッケージはありません
</Text>
}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
initialNumToRender={20}
windowSize={11}
/>
</View>
);
};
const LicenseRow = React.memo(
({
pkg,
expanded,
onPress,
}: {
pkg: OssLicensePackage;
expanded: boolean;
onPress: (key: string) => void;
}) => {
const { colors } = useThemeColors();
const text = expanded ? resolveOssLicenseText(data, pkg) : null;
return (
<View
style={{
backgroundColor: colors.surface,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
}}
>
<TouchableOpacity
onPress={() => onPress(keyOf(pkg))}
style={{ flexDirection: "row", alignItems: "center", padding: 14 }}
accessibilityRole="button"
accessibilityState={{ expanded }}
>
<View style={{ flex: 1 }}>
<Text style={{ color: colors.text, fontSize: 15 }} numberOfLines={1}>
{pkg.name}
</Text>
<Text style={{ color: colors.textTertiary, fontSize: 12, marginTop: 2 }}>
{pkg.version} ・ {pkg.license}
{pkg.note ? ` ・ ${pkg.note}` : ""}
</Text>
</View>
<MaterialCommunityIcons
name={expanded ? "chevron-up" : "chevron-down"}
size={22}
color={colors.iconSecondary}
/>
</TouchableOpacity>
{expanded && (
<View style={{ paddingHorizontal: 14, paddingBottom: 14 }}>
{pkg.repository && (
<TouchableOpacity
onPress={() => openExternalUrl(pkg.repository!)}
style={{ marginBottom: 10 }}
accessibilityRole="link"
>
<Text style={{ color: colors.textAccent, fontSize: 13 }}>
{pkg.repository}
</Text>
</TouchableOpacity>
)}
<Text
selectable
style={{
color: colors.textSecondary,
fontSize: 11,
lineHeight: 16,
fontFamily: Platform.select({ ios: "Menlo", default: "monospace" }),
}}
>
{text ?? `このパッケージにはライセンスファイルが同梱されていません(${pkg.license})。`}
</Text>
</View>
)}
</View>
);
},
);
LicenseRow.displayName = "LicenseRow";
const SectionHeader = ({ title }: { title: string }) => {
const { colors } = useThemeColors();
return (
<Text
style={{
fontSize: 13,
fontWeight: "600",
color: colors.textTertiary,
letterSpacing: 0.5,
marginTop: 12,
marginLeft: 16,
marginBottom: 6,
}}
>
{title}
</Text>
);
};
@@ -22,6 +22,10 @@ import {
} from "@/lib/jrDataSystemEnvironment";
import { STORAGE_KEYS } from "@/constants";
import { AS } from "@/storageControl";
import {
ERROR_BOUNDARY_TEST_SCOPES,
armErrorBoundaryTest,
} from "@/lib/errorBoundaryTest";
export const ResearchToolsSettings = () => {
const navigation = useNavigation();
@@ -132,6 +136,21 @@ export const ResearchToolsSettings = () => {
}
};
const confirmErrorBoundaryTest = (scope: string, label: string) => {
Alert.alert(
"エラー画面テスト",
`${label}でエラー画面を表示します。Sentryにテスト用のイベントが1件送信されます。`,
[
{ text: "キャンセル", style: "cancel" },
{
text: "表示する",
style: "destructive",
onPress: () => armErrorBoundaryTest(scope),
},
],
);
};
const openRecordingDatabase = () => {
const uri = `${backendApiBaseUrl}/recorder?from=eachTrainInfo&userID=${encodeURIComponent(expoPushToken || "")}`;
const params = {
@@ -580,6 +599,49 @@ export const ResearchToolsSettings = () => {
</Text>
</TouchableOpacity>
</View>
<View
style={[
styles.debugSection,
{
backgroundColor: colors.surface,
borderColor: colors.borderSecondary,
},
]}
>
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>
デバッグ: エラー画面テスト
</Text>
<Text
style={[
styles.debugDescription,
{ color: colors.textSecondary },
]}
>
選んだ範囲で意図的に描画エラーを起こし、エラー画面を確認します。タブを選んだ場合は、そのタブを開くとエラー画面が表示されます。
</Text>
<View style={styles.buttonRow}>
{ERROR_BOUNDARY_TEST_SCOPES.map(({ scope, label }) => (
<TouchableOpacity
key={scope}
onPress={() => confirmErrorBoundaryTest(scope, label)}
style={[
styles.neutralButton,
{ backgroundColor: colors.borderSecondary },
]}
>
<Text
style={[
styles.neutralButtonText,
{ color: colors.textPrimary },
]}
>
{label}
</Text>
</TouchableOpacity>
))}
</View>
</View>
</>
)}
</ScrollView>
+47 -18
View File
@@ -4,7 +4,6 @@ import {
Text,
TouchableOpacity,
ScrollView,
Linking,
Image,
} from "react-native";
import * as Updates from "expo-updates";
@@ -13,11 +12,13 @@ import { useNavigation } from "@react-navigation/native";
import { ListItem } from "@rneui/themed";
import TouchableScale from "react-native-touchable-scale";
import { SwitchArea } from "../atom/SwitchArea";
import { useNotification } from "../../stateBox/useNotifications";
import { useNotification } from "@/stateBox/useNotifications";
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
import { useThemeColors, type ColorThemePref } from "@/lib/theme/useThemeColors";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
const versionCode = "7.2.1";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { openExternalUrl } from "@/lib/openExternalUrl";
const versionCode = "7.2.2";
export const SettingTopPage = ({
testNFC,
@@ -42,7 +43,7 @@ export const SettingTopPage = ({
<View style={{ height: 300, padding: 10 }}>
<View style={{ flex: 1 }} />
<Image
source={require("../../assets/Header.png")}
source={require("@/assets/Header.png")}
style={{
aspectRatio: 8.08,
height: undefined,
@@ -88,14 +89,15 @@ export const SettingTopPage = ({
str="起動時に表示する画面"
bool={startPage}
setBool={setStartPage}
falseImage={require("../../assets/configuration/showSetting0.jpg")}
trueImage={require("../../assets/configuration/showSetting1.jpg")}
falseImage={require("@/assets/configuration/showSetting0.jpg")}
trueImage={require("@/assets/configuration/showSetting1.jpg")}
falseText={"リンクメニュー"}
trueText={"列車位置情報"}
/>
<ColorThemePicker value={colorTheme} onChange={setColorTheme} />
<SettingList
string="レイアウト設定"
icon="view-dashboard-outline"
onPress={() =>
navigation.navigate("setting", { screen: "LayoutSettings" })
}
@@ -104,24 +106,28 @@ export const SettingTopPage = ({
<SectionHeader title="カスタマイズ" />
<SettingList
string="お気に入り登録の並び替え"
icon="sort-ascending"
onPress={() =>
navigation.navigate("setting", { screen: "FavoriteSettings" })
}
/>
<SettingList
string="アイコン設定"
icon="image-multiple-outline"
onPress={() =>
navigation.navigate("setting", { screen: "LauncherIconSettings" })
}
/>
<SettingList
string="サウンド設定(β)"
icon="volume-high"
onPress={() =>
navigation.navigate("setting", { screen: "SoundSettings" })
}
/>
<SettingList
string="運行情報設定(β)"
icon="alert-circle-outline"
onPress={() =>
navigation.navigate("setting", { screen: "OperationInfoSettings" })
}
@@ -132,6 +138,7 @@ export const SettingTopPage = ({
string={`通知設定${
expoPushToken == "" ? "(通知設定をオンにしてください)" : "(β)"
}`}
icon="bell-outline"
onPress={() =>
navigation.navigate("setting", { screen: "NotificationSettings" })
}
@@ -139,13 +146,22 @@ export const SettingTopPage = ({
/>
<SettingList
string="情報ソース設定"
icon="database-outline"
onPress={() =>
navigation.navigate("setting", { screen: "DataSourceSettings" })
}
/>
<SettingList
string="接続ステータス"
icon="server-network"
onPress={() =>
navigation.navigate("setting", { screen: "ConnectionStatus" })
}
/>
{showResearchTools && (
<SettingList
string="調査ツール"
icon="tools"
onPress={() =>
navigation.navigate("setting", { screen: "ResearchToolsSettings" })
}
@@ -155,21 +171,24 @@ export const SettingTopPage = ({
<SectionHeader title="その他" />
<SettingList
string="プライバシーポリシー"
icon="shield-lock-outline"
onPress={() =>
Linking.openURL(
"https://nexcloud.haruk.in/sites/press-harukin/JRShikokuApps/policy"
openExternalUrl(
"https://xprocess.haruk.in/JR-shikoku-Apps-Common/NativeAppsPrivacyPolicy"
)
}
/>
<SettingList
string="開発情報"
onPress={() =>
Linking.openURL("https://nexcloud.haruk.in/s/2WFEN5oLPGtrRqT")
}
string="運営Twitter"
icon="twitter"
onPress={() => openExternalUrl("https://twitter.com/Xprocess_main")}
/>
<SettingList
string="運営Twitter"
onPress={() => Linking.openURL("https://twitter.com/Xprocess_main")}
string="オープンソースライセンス"
icon="license"
onPress={() =>
navigation.navigate("setting", { screen: "OssLicenses" })
}
/>
</View>
</ScrollView>
@@ -195,21 +214,31 @@ export const SettingTopPage = ({
);
};
const SettingList = ({ string, onPress, disabled = false }: { string: string; onPress: () => void; disabled?: boolean }) => {
const SettingList = ({
string,
icon,
onPress,
disabled = false,
}: {
string: string;
icon?: React.ComponentProps<typeof MaterialCommunityIcons>["name"];
onPress: () => void;
disabled?: boolean;
}) => {
const { colors } = useThemeColors();
return (
<ListItem
// @ts-ignore: TouchableScale props passed through Component
// @ts-expect-error RNEUIのListItem型はComponentに渡したTouchableScaleのpropsを含まない
activeScale={0.95}
Component={TouchableScale}
bottomDivider
onPress={onPress}
disabled={disabled}
// @ts-ignore: containerStyle type mismatch with TouchableScale Component
containerStyle={{ backgroundColor: colors.surface }}
>
{icon ? <MaterialCommunityIcons name={icon} size={24} color={colors.iconSecondary} /> : null}
<ListItem.Content>
<ListItem.Title style={{ color: colors.text }}>{string}</ListItem.Title>
<ListItem.Title numberOfLines={1} style={{ color: colors.text }}>{string}</ListItem.Title>
</ListItem.Content>
<ListItem.Chevron />
</ListItem>
+2 -2
View File
@@ -9,7 +9,7 @@ import {
import { Switch } from "@rneui/themed";
import { useNavigation } from "@react-navigation/native";
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
import { AS } from "../../storageControl";
import { AS } from "@/storageControl";
import { STORAGE_KEYS } from "@/constants";
import { useThemeColors } from "@/lib/theme";
import { Asset } from "expo-asset";
@@ -22,7 +22,7 @@ import {
import { VoicepeakDebugLogSection } from "@/components/Settings/VoicepeakDebugLogSection";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
const previewSound = require("../../assets/sound/rikka-test.mp3");
const previewSound = require("@/assets/sound/rikka-test.mp3");
export const SoundSettings = () => {
const { goBack } = useNavigation();
+26 -2
View File
@@ -12,9 +12,9 @@ import {
import type { SettingsStackParamList } from "@/types/navigation";
import { createStackNavigator } from "@react-navigation/stack";
import { pushTransitionOptions } from "@/lib/stackOption";
import * as ExpoFelicaReader from "../../modules/expo-felica-reader/src";
import * as ExpoFelicaReader from "@/modules/expo-felica-reader/src";
import * as Updates from "expo-updates";
import { AS } from "../../storageControl";
import { AS } from "@/storageControl";
import { STORAGE_KEYS } from "@/constants";
import { Switch } from "@rneui/themed";
import { SettingTopPage } from "./SettingTopPage";
@@ -28,6 +28,8 @@ import { ResearchToolsSettings } from "./ResearchToolsSettings";
import { FelicaHistoryPage } from "./FelicaHistoryPage";
import { SoundSettings } from "./SoundSettings";
import { OperationInfoSettings } from "./OperationInfoSettings";
import { ConnectionStatusSettings } from "./ConnectionStatusSettings";
import { OssLicenses } from "./OssLicenses";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
import {
normalizeIconDisplayMode,
@@ -298,6 +300,28 @@ export default function Setting(props) {
/>
)}
</Stack.Screen>
<Stack.Screen
name="ConnectionStatus"
options={{
gestureEnabled: true,
...pushTransitionOptions,
cardOverlayEnabled: true,
headerTransparent: true,
headerShown: false,
}}
component={ConnectionStatusSettings}
/>
<Stack.Screen
name="OssLicenses"
options={{
gestureEnabled: true,
...pushTransitionOptions,
cardOverlayEnabled: true,
headerTransparent: true,
headerShown: false,
}}
component={OssLicenses}
/>
</Stack.Navigator>
);
}
+3 -3
View File
@@ -3,12 +3,12 @@ import { lightColors } from "@/lib/theme";
import { MaterialCommunityIcons, Ionicons, FontAwesome5 } from "@expo/vector-icons";
import { FC } from "react";
import {
Linking,
Text,
TouchableOpacity,
useWindowDimensions,
View,
} from "react-native";
import { openExternalUrl } from "@/lib/openExternalUrl";
type Props = {
item: StationProps[];
@@ -129,14 +129,14 @@ export const SpotSign: FC<Props> = ({ item, isCurrentStation }) => {
}}
>
<TouchableOpacity
onPress={() => item[0].StationMap && Linking.openURL(item[0].StationMap)}
onPress={() => item[0].StationMap && openExternalUrl(item[0].StationMap)}
hitSlop={{ top: 8, bottom: 8, left: 12, right: 12 }}
>
<Ionicons name="map-outline" size={cardHeight * 0.15} color="white" />
</TouchableOpacity>
{item[0].JrHpUrl ? (
<TouchableOpacity
onPress={() => Linking.openURL(item[0].JrHpUrl)}
onPress={() => openExternalUrl(item[0].JrHpUrl)}
hitSlop={{ top: 8, bottom: 8, left: 12, right: 12 }}
>
<MaterialCommunityIcons name="web" size={cardHeight * 0.15} color="white" />
+6 -10
View File
@@ -1,6 +1,6 @@
import * as SplashScreen from "expo-splash-screen";
import { useCallback, useEffect, useRef, useState } from "react";
import { Platform, StyleSheet } from "react-native";
import { StyleSheet } from "react-native";
import Animated, {
Easing,
useAnimatedStyle,
@@ -19,24 +19,22 @@ const EASE_OUT = Easing.bezier(0.23, 1, 0.32, 1);
// Keep the native splash visible until the React overlay has been mounted.
// This must run at module scope so the native splash is not hidden too early.
if (Platform.OS !== "web") {
void SplashScreen.preventAutoHideAsync().catch(() => {
// The native splash may already be hidden during a reload.
});
}
void SplashScreen.preventAutoHideAsync().catch(() => {
// The native splash may already be hidden during a reload.
});
export default function StartupOverlay() {
const reducedMotion = useReducedMotion();
const opacity = useSharedValue(1);
const scale = useSharedValue(1);
const [visible, setVisible] = useState(Platform.OS !== "web");
const [visible, setVisible] = useState(true);
const hasStarted = useRef(false);
const fallbackTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const animationTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const animationFrame = useRef<number | null>(null);
const finish = useCallback(() => {
if (Platform.OS === "web" || hasStarted.current) return;
if (hasStarted.current) return;
hasStarted.current = true;
if (fallbackTimer.current !== null) {
@@ -80,8 +78,6 @@ export default function StartupOverlay() {
}, [opacity, reducedMotion, scale]);
useEffect(() => {
if (Platform.OS === "web") return;
// A local bundled image normally fires onLoadEnd immediately. The
// fallback keeps a broken image from leaving the native splash forever.
fallbackTimer.current = setTimeout(finish, FALLBACK_DELAY);
@@ -101,10 +101,10 @@ export const ExGridSimpleViewItem: FC<{
const payload = {
currentStation: returnDataBase,
navigate,
//@ts-ignore
goTo: "StationDiagram",
useShow: () => SheetManager.show("StationDetailView", { payload }),
onExit: () => SheetManager.hide("StationDetailView"),
}; //@ts-ignore
};
setTimeout(() => SheetManager.show("StationDetailView", { payload }), 50);
} else {
SheetManager.hide("StationDetailView");
@@ -128,7 +128,6 @@ export const ExGridSimpleViewItem: FC<{
from: d.isOperating ? null : "AllTrainIDList",
};
SheetManager.show("EachTrainInfo", {
//@ts-ignore
payload,
onClose: () => {
//alert(data);
+2 -3
View File
@@ -108,10 +108,10 @@ export const ExGridViewItem: FC<{
const payload = {
currentStation: returnDataBase,
navigate,
//@ts-ignore
goTo: "StationDiagram",
useShow: () => SheetManager.show("StationDetailView", { payload }),
onExit: () => SheetManager.hide("StationDetailView"),
}; //@ts-ignore
};
setTimeout(() => SheetManager.show("StationDetailView", { payload }), 50);
} else {
SheetManager.hide("StationDetailView");
@@ -132,7 +132,6 @@ export const ExGridViewItem: FC<{
from: d.isOperating ? null :"AllTrainIDList",
};
SheetManager.show("EachTrainInfo", {
//@ts-ignore
payload,
onClose: () => {
//alert(data);
+2 -3
View File
@@ -181,10 +181,10 @@ export const ListViewItem: FC<{
const payload = {
currentStation: returnDataBase,
navigate,
//@ts-ignore
goTo: "StationDiagram",
useShow: () => SheetManager.show("StationDetailView", { payload }),
onExit: () => SheetManager.hide("StationDetailView"),
}; //@ts-ignore
};
setTimeout(() => SheetManager.show("StationDetailView", { payload }), 50);
} else {
SheetManager.hide("StationDetailView");
@@ -205,7 +205,6 @@ export const ListViewItem: FC<{
from: "AllTrainIDList",
};
SheetManager.show("EachTrainInfo", {
//@ts-ignore
payload,
onClose: () => {
//alert(data);
@@ -69,11 +69,16 @@ export const StationDiagramView: FC<props> = ({ route }) => {
// フィルタリング:終点路線、種別、行先、関係停車駅
const { keyList, allTrainDiagram } = useTrainDiagram();
const { allCustomTrainData } = useCustomTrainData();
const { todayOperation } = useOperationLogs();
const { allCustomTrainData, activateCustomTrainData } = useCustomTrainData();
const { todayOperation, activateOperationLogs } = useOperationLogs();
const { useUnyohub: unyohubEnabled } = useUnyohub();
const { useElesite: elesiteEnabled } = useElesite();
useEffect(() => {
activateCustomTrainData();
activateOperationLogs();
}, [activateCustomTrainData, activateOperationLogs]);
const { goBack } = useNavigation();
const { colors, fixed } = useThemeColors();
const tabBarHeight = useBottomTabBarHeight();
@@ -361,7 +366,7 @@ export const StationDiagramView: FC<props> = ({ route }) => {
autoPlay
loop
style={{ width: 80, height: 80 }}
source={require("../../assets/51690-loading-diamonds.json")}
source={require("@/assets/51690-loading-diamonds.json")}
/>
<Text style={{ color: colors.text, marginTop: 12, fontSize: 14 }}>読み込み中...</Text>
</View>
+1 -1
View File
@@ -38,7 +38,7 @@ export const MapPin: FC<Props> = (props) => {
>
<View style={styles.markerWrap}>
<Image
source={require("../../assets/reccha-small.png")}
source={require("@/assets/reccha-small.png")}
style={styles.markerImage}
resizeMode="contain"
/>
+1 -1
View File
@@ -1,7 +1,7 @@
import React, { FC } from "react";
import { View, Text, TouchableOpacity } from "react-native";
import { useThemeColors } from "@/lib/theme";
import lineColorList from "../../assets/originData/lineColorList";
import lineColorList from "@/assets/originData/lineColorList";
type Props = {
+1 -1
View File
@@ -12,7 +12,7 @@ export const SimpleSwitch = ({
setBool,
color,
value,
image = require("../../assets/icons.png"),
image = require("@/assets/icons.png"),
subText = "",
}) => {
const { colors } = useThemeColors();
+5 -4
View File
@@ -1,5 +1,5 @@
import React, { useRef, useState, useEffect, useLayoutEffect } from "react";
import { View, Text, TouchableOpacity, Linking, Platform } from "react-native";
import { View, Text, TouchableOpacity, Platform } from "react-native";
import MapView, { Marker } from "react-native-maps";
import { useCurrentTrainView } from "../stateBox/useCurrentTrain";
import { useNavigation } from "@react-navigation/native";
@@ -13,6 +13,7 @@ import { MapsButton } from "./TrainMenu/MapsButton";
import { useStationList } from "@/stateBox/useStationList";
import { useThemeColors } from "@/lib/theme";
import { useUserPosition } from "@/stateBox/useUserPosition";
import { openExternalUrl } from "@/lib/openExternalUrl";
const ANDROID_USER_LOCATION_MARKER_SIZE = 18;
const ANDROID_USER_LOCATION_INNER_SIZE = 8;
@@ -223,8 +224,8 @@ export default function TrainMenu({ style }) {
icon="train-car"
flex={1}
onPressButton={() =>
navigate("howto", {
info: "https://xprocess.haruk.in/JR-shikoku-Apps-Common/info/train-position",
navigate("generalWebView", {
uri: "https://xprocess.haruk.in/JR-shikoku-Apps-Common/info/train-position",
})
}
>
@@ -243,7 +244,7 @@ export default function TrainMenu({ style }) {
icon="clipboard-list-outline"
flex={1}
onPressButton={() =>
Linking.openURL(
openExternalUrl(
"https://nexcloud.haruk.in/apps/forms/ZRHjWFF7znr5Xjr2"
)
}
-9
View File
@@ -1,9 +0,0 @@
import type { StyleProp, ViewStyle } from "react-native";
type TrainMenuProps = {
style?: StyleProp<ViewStyle>;
};
export default function TrainMenu(_props: TrainMenuProps) {
return null;
}
@@ -2,7 +2,7 @@ import { View, Text } from "react-native";
import {
useCurrentTrainData,
useCurrentTrainView,
} from "../../../stateBox/useCurrentTrain";
} from "@/stateBox/useCurrentTrain";
import LottieView from "lottie-react-native";
import { Ionicons, AntDesign } from "@expo/vector-icons";
import { useStationList } from "@/stateBox/useStationList";
@@ -43,7 +43,7 @@ export const Header = ({ station }) => {
autoPlay
loop
style={{ width: 40, height: 40, marginRight: 30 }}
source={require("../../../assets/51690-loading-diamonds.json")}
source={require("@/assets/51690-loading-diamonds.json")}
/>
) : currentTrainLoading == "error" ? (
<Ionicons

Some files were not shown because too many files have changed in this diff Show More