20 Commits

Author SHA1 Message Date
harukin-expo-dev-env
58b2049b24 Merge commit '29bc89f1836ea3a5ec7092ed78c60d01a89569ed' into develop 2026-03-17 10:22:49 +00:00
harukin-expo-dev-env
29bc89f183 feat: Felicaウィジェット ディープリンク対応 + 通知タップルーティング\n\n- FelicaQuickAccessWidget: OPEN_URI で jrshikoku://open/felica へディープリンク\n- App.tsx: ディープリンクハンドラ (routeFromUrl → FelicaHistoryPage)\n- Apps.tsx: linking config + rootNavigationRef 接続\n- lib/rootNavigation.ts: グローバルナビゲーション ref 追加\n- useNotifications.tsx: 通知タップ時のルーティング (遅延速報EX/怪レい列車/運行情報)\n- docs/widget-overview.md: ウィジェット機能の概要ドキュメント" 2026-03-17 10:00:21 +00:00
harukin-expo-dev-env
adfe69b72f feat: expo-audioによる音声再生機能を追加
- expo-audio依存を追加、app.jsonにプラグイン設定
- 設定画面ヘッダー画像タップで音声再生
- expo-assetでOTA対応のアセット解決 + file:// prefix除去(SDK52バグ回避)
- 音声ファイルはmp3のみ保持(wav/m4a/ogg削除)
- SDK53以降でinterruptionMode: duckOthers対応予定
2026-03-17 09:43:54 +00:00
harukin-expo-dev-env
f387479ff7 feat(Felica): implement Felica quick access widget and update snapshot handling 2026-03-17 06:17:14 +00:00
harukin-expo-dev-env
684aaeb92f fix(webViewInjectjavascript): improve operation list fetching logic to handle null data 2026-03-17 02:33:52 +00:00
harukin-expo-dev-env
0a8d5ca2b6 feat(FixedContentBottom): add buttons for ダイヤグラフ and 運用チャート with navigation 2026-03-17 02:32:21 +00:00
harukin-expo-dev-env
48b38a2fa3 fix(TrainDataSources): update URL opening logic and hide sheet on link click
fix(ListViewItem): ensure source index wraps around for vehicle sources display
2026-03-15 08:28:47 +00:00
harukin-expo-dev-env
aeb043cac5 fix(ListViewItem): update dependency array in useEffect for source index and fade animation 2026-03-15 08:07:24 +00:00
harukin-expo-dev-env
2c6ceb73d8 fix(ScrollingDescription): round width values for text and container layout 2026-03-15 07:57:15 +00:00
harukin-expo-dev-env
f214f45fef feat(felica): add vehicle source display and selection modal in StationDiagramView and ListView components 2026-03-15 06:11:48 +00:00
harukin-expo-dev-env
616846e1cd feat(felica): enhance history row with balance calculation and long press copy functionality 2026-03-15 03:41:30 +00:00
harukin-expo-dev-env
be88a46df1 feat(felica): update build and version codes, enhance Felica history page with card type display and scanning functionality 2026-03-15 03:01:07 +00:00
harukin-expo-dev-env
7386ec09fc feat(felica): update station label handling for non-transit process types 2026-03-14 17:23:24 +00:00
harukin-expo-dev-env
a068dabc75 Merge commit '8fda56793adb6378e5b253738e6b79e848ca8ec1' into develop 2026-03-14 13:57:37 +00:00
harukin-expo-dev-env
8fda56793a Merge commit '83c7dbde630da01d608558c687ca178bbae5e020' into feature/felica-example 2026-03-14 13:55:27 +00:00
harukin-expo-dev-env
83c7dbde63 Merge commit '676fbf7b646a0ffde785f0bec47f9a3b582b4df3' into develop 2026-03-14 13:55:18 +00:00
harukin-expo-dev-env
676fbf7b64 feat: add nearest station tracking and display functionality 2026-03-14 13:55:05 +00:00
harukin-expo-dev-env
8bc726628a feat(felica): add station name lookup from FeliCa history
- Add regionCode (byte[15]) to history entry in Android/iOS native code
- areaCode = regionCode >> 6 determines the transit area (0-3)
- stationId = (areaCode<<16) | (lineCode<<8) | stationCode
- Add lib/felicaStationMap.ts with 5900+ station entries from
  metrodroid/felica_stations.db3 (GPL-3.0)
- FelicaHistoryPage now shows station names instead of raw L/S codes
- Falls back to raw code format if station is not in the database
2026-03-14 13:21:47 +00:00
harukin-expo-dev-env
ee22d21862 fix: FeliCa履歴サービスコードを0x090Dから0x090Fに修正、バイトレイアウト修正
- 履歴サービスコード修正 (Android/iOS): 0x090D → 0x090F
  - 0x090F が交通系IC共通の利用履歴サービスコード(tattn/NFCReader等で確認)
  - 0x090D は存在しないサービスで常に空配列が返っていた
- parseHistoryBlock バイトレイアウト修正 (Android/iOS):
  - [6] → 入場路線コード(旧: 入場時刻30分単位)
  - [7] → 入場駅コード  (旧: 出場時刻30分単位)
  - [8] → 出場路線コード(旧: 残高低位)
  - [9] → 出場駅コード  (旧: 残高高位)
  - [10-11] → 残高LE    (rolex: 路線コード)
  - [13] → 会社コード   (旧: byte[14])
- FelicaHistoryEntry から entryHour/entryMinute/exitHour/exitMinute 削除
- FelicaHistoryPage UI から時刻表示を削除、路線+駅コード表示に更新
- PiTaPa は systemCode 0003 (交通系IC共通) を実装しているため追加設定不要
2026-03-14 11:27:51 +00:00
harukin-expo-dev-env
3894694c9b fix: podspecのpackage.jsonパスとsource_filesを修正、.gitignoreのios除外を修正 2026-03-14 09:56:12 +00:00
34 changed files with 7162 additions and 218 deletions

4
.gitignore vendored
View File

@@ -54,4 +54,6 @@ Thumbs.db
.cache/
android/
ios/
!modules/**/android/
ios/
!modules/**/ios/

53
App.tsx
View File

@@ -1,5 +1,5 @@
import React, { useEffect } from "react";
import { Platform, UIManager } from "react-native";
import { Linking, Platform, UIManager } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import "./utils/disableFontScaling"; // グローバルなフォントスケーリング無効化
import { AppContainer } from "./Apps";
@@ -20,6 +20,7 @@ import { buildProvidersTree } from "./lib/providerTreeProvider";
import { StationListProvider } from "./stateBox/useStationList";
import { NotificationProvider } from "./stateBox/useNotifications";
import { UserPositionProvider } from "./stateBox/useUserPosition";
import { rootNavigationRef } from "./lib/rootNavigation";
LogBox.ignoreLogs([
"ViewPropTypes will be removed",
@@ -37,6 +38,56 @@ export default function App() {
UpdateAsync();
}, []);
useEffect(() => {
const openFelicaPage = (retryCount = 0) => {
if (!rootNavigationRef.isReady()) {
if (retryCount < 8) {
setTimeout(() => openFelicaPage(retryCount + 1), 250);
}
return;
}
rootNavigationRef.navigate("topMenu", {
screen: "setting",
params: {
screen: "FelicaHistoryPage",
},
} as any);
};
const routeFromUrl = (url: string, retryCount = 0) => {
const normalized = (url || "").toLowerCase();
if (!normalized) return;
const isFelicaUrl =
normalized.includes("felicahistorypage") ||
normalized.includes("open/felica");
if (!isFelicaUrl) return;
if (!rootNavigationRef.isReady()) {
if (retryCount < 8) {
setTimeout(() => routeFromUrl(url, retryCount + 1), 250);
}
return;
}
openFelicaPage();
};
Linking.getInitialURL().then((url) => {
if (url) routeFromUrl(url);
});
const sub = Linking.addEventListener("url", ({ url }) => {
routeFromUrl(url);
});
return () => {
sub.remove();
};
}, []);
const ProviderTree = buildProvidersTree([
AllTrainDiagramProvider,
NotificationProvider,

View File

@@ -1,5 +1,5 @@
import React from "react";
import { NavigationContainer, NavigationContainerRef } from "@react-navigation/native";
import { NavigationContainer } from "@react-navigation/native";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { Platform } from "react-native";
import { useFonts } from "expo-font";
@@ -9,6 +9,7 @@ import { Top } from "./Top";
import { MenuPage } from "./MenuPage";
import { useAreaInfo } from "./stateBox/useAreaInfo";
import "./components/ActionSheetComponents/sheets";
import { rootNavigationRef } from "./lib/rootNavigation";
type RootTabParamList = {
positions: undefined;
@@ -28,7 +29,30 @@ type TabProps = {
export function AppContainer() {
const Tab = createBottomTabNavigator<RootTabParamList>();
const { areaInfo, areaIconBadgeText, isInfo } = useAreaInfo();
const navigationRef = React.useRef<NavigationContainerRef<RootTabParamList>>(null);
const linking = {
prefixes: ["jrshikoku://"],
config: {
screens: {
positions: {
screens: {
Apps: "positions/apps",
},
},
topMenu: {
screens: {
menu: "topMenu/menu",
setting: {
screens: {
settingTopPage: "topMenu/setting",
FelicaHistoryPage: "topMenu/setting/FelicaHistoryPage",
},
},
},
},
information: "information",
},
},
};
const getTabProps = (
name: keyof RootTabParamList,
@@ -54,7 +78,7 @@ export function AppContainer() {
"DiaPro": require("./assets/fonts/DiaPro-Regular.otf"),
});
return (
<NavigationContainer ref={navigationRef}>
<NavigationContainer ref={rootNavigationRef} linking={linking}>
{/* @ts-expect-error - Tab.Navigator type definition issue */}
<Tab.Navigator
initialRouteName="topMenu"

View File

@@ -2,6 +2,7 @@
"expo": {
"name": "JR四国非公式",
"slug": "jrshikoku",
"scheme": "jrshikoku",
"platforms": [
"ios",
"android",
@@ -22,7 +23,7 @@
"**/*"
],
"ios": {
"buildNumber": "50",
"buildNumber": "53",
"supportsTablet": false,
"bundleIdentifier": "jrshikokuinfo.xprocess.hrkn",
"config": {
@@ -44,7 +45,35 @@
},
"android": {
"package": "jrshikokuinfo.xprocess.hrkn",
"versionCode": 28,
"versionCode": 29,
"intentFilters": [
{
"action": "VIEW",
"data": [
{
"scheme": "jrshikoku"
}
],
"category": [
"BROWSABLE",
"DEFAULT"
]
},
{
"action": "VIEW",
"data": [
{
"scheme": "jrshikoku",
"host": "open",
"pathPrefix": "/felica"
}
],
"category": [
"BROWSABLE",
"DEFAULT"
]
}
],
"permissions": [
"ACCESS_FINE_LOCATION",
"NFC",
@@ -460,7 +489,8 @@
"kotlinVersion": "1.9.25"
}
}
]
],
"expo-audio"
]
}
}

BIN
assets/sound/rikka-test.mp3 Normal file

Binary file not shown.

View File

@@ -3,7 +3,7 @@ import { View, Platform, Text } from "react-native";
import ActionSheet ,{ ScrollView } from "react-native-actions-sheet";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ListItem } from "@rneui/themed";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { MaterialCommunityIcons, Foundation } from "@expo/vector-icons";
import { Linking } from "react-native";
import TouchableScale from "react-native-touchable-scale";
export const Social = () => {
@@ -60,6 +60,26 @@ export const Social = () => {
JR四国公式SNS一族
</Text>
</View>
<ListItem
bottomDivider
onPress={() => Linking.openURL("tel:0570-00-4592")}
friction={90}
tension={100}
activeScale={0.95}
Component={TouchableScale}
containerStyle={{ backgroundColor: "#8AE234" }}
>
<Foundation name="telephone" color="white" size={30} />
<ListItem.Content>
<ListItem.Title style={{ color: "white", fontWeight: "bold" }}>
JR四国案内センター
</ListItem.Title>
<ListItem.Subtitle style={{ color: "white" }}>
0570-00-45928:0020:00
</ListItem.Subtitle>
</ListItem.Content>
<ListItem.Chevron color="white" />
</ListItem>
<ScrollView
style={{
padding: 10,

View File

@@ -6,6 +6,7 @@ import {
StyleSheet,
ScrollView,
Platform,
Linking,
Image,
Animated,
} from "react-native";
@@ -407,7 +408,8 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
);
const url =
matchedTrain?.timetable_url || "https://www.elesite-next.com/";
openWebView(url, true);
SheetManager.hide("TrainDataSources");
Linking.openURL(url);
}}
/>
)}

View File

@@ -0,0 +1,126 @@
import React from "react";
import dayjs from "dayjs";
import { FlexWidget, TextWidget } from "react-native-android-widget";
import { AS } from "../../storageControl";
import { STORAGE_KEYS } from "../../constants";
type LastFelicaSnapshot = {
balance: number;
idm: string;
systemCode?: string;
scannedAt: string;
};
export async function getFelicaQuickAccessData() {
const nowText = dayjs().format("HH:mm");
const snapshot = (await AS.getItem(STORAGE_KEYS.FELICA_LAST_SNAPSHOT).catch(
() => null
)) as LastFelicaSnapshot | null;
const hasBalance =
snapshot != null && typeof snapshot.balance === "number" && snapshot.balance >= 0;
return {
nowText,
amountText: hasBalance ? `\u00A5${snapshot.balance.toLocaleString()}` : "未読取",
detailText:
snapshot?.scannedAt != null
? `最終読取: ${snapshot.scannedAt}`
: "カードをタップして読取開始",
};
}
export function FelicaQuickAccessWidget({
nowText,
amountText,
detailText,
}: {
nowText: string;
amountText: string;
detailText: string;
}) {
return (
<FlexWidget
style={{
height: "match_parent",
width: "match_parent",
justifyContent: "center",
alignItems: "center",
backgroundColor: "#0B1D2A",
borderRadius: 20,
padding: 14,
}}
clickAction="OPEN_URI"
clickActionData={{
uri: "jrshikoku://open/felica",
}}
>
<FlexWidget
style={{
width: "match_parent",
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 8,
}}
>
<TextWidget
text="Quick Access"
style={{
color: "#8EC5FF",
fontSize: 14,
fontWeight: "bold",
}}
/>
<TextWidget
text={nowText}
style={{
color: "#D3E6FF",
fontSize: 12,
}}
/>
</FlexWidget>
<TextWidget
text="ICカード残高"
style={{
width: "match_parent",
color: "#A7BED4",
fontSize: 13,
}}
/>
<TextWidget
text={amountText}
style={{
width: "match_parent",
color: "#FFFFFF",
fontSize: 34,
fontWeight: "bold",
marginTop: 2,
}}
/>
<TextWidget
text={detailText}
style={{
width: "match_parent",
color: "#9BB5CC",
fontSize: 12,
marginTop: 4,
}}
/>
<TextWidget
text="タップでFelicaスキャン"
style={{
width: "match_parent",
color: "#7BD9C2",
fontSize: 13,
marginTop: 8,
fontWeight: "bold",
}}
/>
</FlexWidget>
);
}

View File

@@ -2,11 +2,16 @@ import React from "react";
import { TraInfoEXWidget, getDelayData } from "./TraInfoEXWidget";
import { ToastAndroid } from "react-native";
import { InfoWidget, getInfoString } from "./InfoWidget";
import {
FelicaQuickAccessWidget,
getFelicaQuickAccessData,
} from "./FelicaQuickAccessWidget";
import { AS } from "../../storageControl";
export const nameToWidget = {
JR_shikoku_train_info: TraInfoEXWidget,
Info_Widget: InfoWidget,
JR_shikoku_apps_shortcut: FelicaQuickAccessWidget,
};
export async function widgetTaskHandler(props) {
@@ -30,6 +35,12 @@ export async function widgetTaskHandler(props) {
case "WIDGET_UPDATE":
case "WIDGET_CLICK":
case "WIDGET_RESIZED":
if (widgetInfo.widgetName === "JR_shikoku_apps_shortcut") {
const quickData = await getFelicaQuickAccessData();
renderWidget(<FelicaQuickAccessWidget {...quickData} />);
break;
}
switch (WidgetName) {
case "Info_Widget": {
const { time, text } = await getInfoString();

View File

@@ -4,6 +4,7 @@ import { useKeepAwake } from "expo-keep-awake";
import Constants from "expo-constants";
import { FixedTrain } from "./FixedPositionBox/FixedTrainBox";
import { FixedStation } from "./FixedPositionBox/FixedStationBox";
import { FixedNearestStationBox } from "./FixedPositionBox/FixedNearestStationBox";
import { useEffect } from "react";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
@@ -35,6 +36,9 @@ export const FixedPositionBox = () => {
{fixedPosition.type === "train" && (
<FixedTrain trainID={fixedPosition.value} />
)}
{fixedPosition.type === "nearestStation" && (
<FixedNearestStationBox />
)}
</View>
);
};

View File

@@ -0,0 +1,32 @@
import { useCurrentTrain } from "@/stateBox/useCurrentTrain";
import { View, Text } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { FixedStation } from "./FixedStationBox";
export const FixedNearestStationBox = () => {
const { nearestStationID } = useCurrentTrain();
if (!nearestStationID) {
return (
<View
style={{
flex: 1,
flexDirection: "row",
backgroundColor: "white",
alignItems: "center",
paddingHorizontal: 12,
borderBottomWidth: 2,
borderBottomColor: "#0099CC",
}}
pointerEvents="box-none"
>
<Ionicons name="navigate-circle-outline" size={20} color="#0099CC" />
<Text style={{ marginLeft: 6, color: "#0099CC", fontSize: 14 }}>
...
</Text>
</View>
);
}
return <FixedStation stationID={nearestStationID} />;
};

View File

@@ -19,8 +19,8 @@ import { useTrainMenu } from "@/stateBox/useTrainMenu";
import { Ionicons } from "@expo/vector-icons";
import { useNavigation } from "@react-navigation/native";
import { FC, useEffect, useState } from "react";
import { LayoutAnimation, Text, TouchableOpacity, View } from "react-native";
import { FC, useEffect, useRef, useState } from "react";
import { Animated, LayoutAnimation, Text, TouchableOpacity, View } from "react-native";
import { SheetManager } from "react-native-actions-sheet";
type props = {
@@ -31,6 +31,7 @@ export const FixedStation: FC<props> = ({ stationID }) => {
const { mapSwitch } = useTrainMenu();
const {
currentTrain,
fixedPosition,
setFixedPosition,
fixedPositionSize,
setFixedPositionSize,
@@ -38,6 +39,24 @@ export const FixedStation: FC<props> = ({ stationID }) => {
const { getStationDataFromId } = useStationList();
const { navigate } = useNavigation();
const [station, setStation] = useState<StationProps[]>([]);
// GPS追従中の点滅アニメーション
const pulseAnim = useRef(new Animated.Value(1)).current;
const isGpsFollowing = fixedPosition?.type === "nearestStation";
useEffect(() => {
if (!isGpsFollowing) {
pulseAnim.setValue(1);
return;
}
const loop = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, { toValue: 0.4, duration: 600, useNativeDriver: true }),
Animated.timing(pulseAnim, { toValue: 1, duration: 600, useNativeDriver: true }),
])
);
loop.start();
return () => loop.stop();
}, [isGpsFollowing]);
useEffect(() => {
const data = getStationDataFromId(stationID);
setStation(data);
@@ -245,16 +264,27 @@ export const FixedStation: FC<props> = ({ stationID }) => {
height: 26,
}}
>
<Ionicons name="lock-closed" size={15} color="white" />
<Text
style={{
color: "white",
fontSize: 15,
paddingRight: 5,
}}
>
</Text>
{isGpsFollowing ? (
<Animated.View
style={{
flexDirection: "row",
alignItems: "center",
opacity: pulseAnim,
}}
>
<Ionicons name="navigate" size={15} color="white" />
<Text style={{ color: "white", fontSize: 15, paddingRight: 5, paddingLeft: 3 }}>
GPS追従中
</Text>
</Animated.View>
) : (
<>
<Ionicons name="lock-closed" size={15} color="white" />
<Text style={{ color: "white", fontSize: 15, paddingRight: 5 }}>
</Text>
</>
)}
<Ionicons name="close" size={15} color="white" />
</View>

View File

@@ -11,6 +11,8 @@ import Ionicons from "react-native-vector-icons/Ionicons";
import { SearchUnitBox } from "@/components/Menu/RailScope/SearchUnitBox";
import { StationSource } from "@/types";
import { STORAGE_KEYS } from "@/constants";
import { useCurrentTrain } from "@/stateBox/useCurrentTrain";
import { useNavigation } from "@react-navigation/native";
export const CarouselTypeChanger = ({
locationStatus,
@@ -31,6 +33,20 @@ export const CarouselTypeChanger = ({
mapMode: boolean;
setMapMode: React.Dispatch<React.SetStateAction<boolean>>;
}) => {
const { fixedPosition, setFixedPosition } = useCurrentTrain();
const { navigate } = useNavigation();
const isGpsFollowing = fixedPosition?.type === "nearestStation";
const handleGpsFollowLongPress = () => {
returnToDefaultMode();
if (isGpsFollowing) {
setFixedPosition({ type: null, value: null });
} else {
navigate("positions", { screen: "Apps" } as any);
setFixedPosition({ type: "nearestStation", value: null });
}
};
const returnToDefaultMode = () => {
LayoutAnimation.configureNext({
duration: 300,
@@ -96,9 +112,10 @@ export const CarouselTypeChanger = ({
AS.setItem(STORAGE_KEYS.STATION_LIST_MODE, "position");
setSelectedCurrentStation(0);
}}
onLongPress={handleGpsFollowLongPress}
>
<Ionicons
name="locate-outline"
name={isGpsFollowing ? "navigate" : "locate-outline"}
size={14}
color="white"
style={{ margin: 5 }}

View File

@@ -154,22 +154,21 @@ export const FixedContentBottom = (props) => {
<TouchableOpacity
style={{
flex: 1,
backgroundColor: "#8AE234",
backgroundColor: "#00796B",
borderColor: "#0099CC",
padding: 10,
borderWidth: 1,
margin: 2,
alignItems: "center",
}}
onPress={() => Linking.openURL("tel:0570-00-4592")}
onPress={() => props.navigate("setting", { screen: "FelicaHistoryPage" })}
>
<Text style={{ color: "white", fontWeight: "bold", fontSize: 20 }}>
JR四国案内センター
IC残高
</Text>
<Foundation name="telephone" color="white" size={50} />
<Text style={{ color: "white" }}>0570-00-4592</Text>
<Text style={{ color: "white" }}>(8:00~20:00 )</Text>
<Text style={{ color: "white" }}>()</Text>
<MaterialCommunityIcons name="contactless-payment" color="white" size={50} />
<Text style={{ color: "white" }}>Felica対応ICカードの</Text>
<Text style={{ color: "white" }}></Text>
</TouchableOpacity>
</View>
<TextBox
@@ -227,6 +226,36 @@ export const FixedContentBottom = (props) => {
<Ionicons name="search" color="white" size={40} />
</TextBox>
</View>
<View style={{ flexDirection: "row" }}>
<TextBox
backgroundColor="#2980B9"
flex={1}
onPressButton={() => {
const uri = `https://jr-shikoku-data-system.pages.dev/diagram-graph?userID=${expoPushToken}&from=eachTrainInfo`;
props.navigate("generalWebView", { uri, useExitButton: false });
SheetManager.hide("EachTrainInfo");
}}
>
<Text style={{ color: "white", fontWeight: "bold", fontSize: 20 }}>
</Text>
<MaterialCommunityIcons name="chart-timeline" color="white" size={40} />
</TextBox>
<TextBox
backgroundColor="#C0392B"
flex={1}
onPressButton={() => {
const uri = `https://jr-shikoku-data-system.pages.dev/diagram-chart?userID=${expoPushToken}&from=eachTrainInfo`;
props.navigate("generalWebView", { uri, useExitButton: false });
SheetManager.hide("EachTrainInfo");
}}
>
<Text style={{ color: "white", fontWeight: "bold", fontSize: 20 }}>
</Text>
<MaterialCommunityIcons name="chart-gantt" color="white" size={40} />
</TextBox>
</View>
<Text style={{ fontWeight: "bold", fontSize: 20 }}></Text>
<TextBox
backgroundColor="rgb(88, 101, 242)"

View File

@@ -1,17 +1,53 @@
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import {
View,
Text,
TouchableOpacity,
ScrollView,
ActivityIndicator,
StyleSheet,
TouchableOpacity,
Platform,
} from "react-native";
import { useNavigation } from "@react-navigation/native";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import * as Clipboard from "expo-clipboard";
import { BigButton } from "../atom/BigButton";
import { SheetHeaderItem } from "../atom/SheetHeaderItem";
import * as ExpoFelicaReader from "../../modules/expo-felica-reader/src";
import type { FelicaCardInfo, FelicaHistoryEntry } from "../../modules/expo-felica-reader/src";
import { lookupFelicaStation } from "../../lib/felicaStationMap";
import { AS } from "../../storageControl";
import { STORAGE_KEYS } from "../../constants";
import { requestWidgetUpdate } from "react-native-android-widget";
import {
FelicaQuickAccessWidget,
getFelicaQuickAccessData,
} from "../AndroidWidget/FelicaQuickAccessWidget";
// IDm先頭2バイト4 hex chars→ カード種別
// FeliCa Networks が発行者ごとに Manufacture Code を割り当てている
const IDM_PREFIX_LABEL: Record<string, string> = {
"0308": "Suica",
"0316": "Kitaca",
"0311": "icsca",
"0313": "nimoca",
"0315": "はやかけん",
"031d": "ICOCA",
"0321": "SUGOCA",
"0350": "TOICA",
"030d": "manaca",
"0520": "PASMO",
"0b04": "PiTaPa",
};
/**
* IDmの先頭2バイト発行者コードからカード種別を返す。
* 不明な場合は "交通系ICカード" を返す。
*/
function cardTypeLabel(idm: string): string {
const prefix = idm.substring(0, 4).toLowerCase();
return IDM_PREFIX_LABEL[prefix] ?? "交通系ICカード";
}
// 処理種別コード → ラベル
const PROCESS_TYPE_LABEL: Record<number, string> = {
@@ -25,39 +61,82 @@ const PROCESS_TYPE_LABEL: Record<number, string> = {
0x62: "チャージ",
};
function formatTime(hour: number, minute: number): string {
if (hour === 0 && minute === 0) return "--:--";
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
}
// byte[6-9] が駅コードではなく端末ID等として使われる processType
// (metrodroid の isProductSale / CONSOLE_CHARGE 相当)
// terminalType (byte[0]) が 0xC7(POS) / 0xC8(自販機) の場合も同様
const NON_TRANSIT_PROCESS_TYPES = new Set([
0x46, // 物販
0x62, // チャージ
0x14, // タクシー
]);
const NON_TRANSIT_TERMINAL_TYPES = new Set([
0xc7, // POS端末
0xc8, // 自動販売機
]);
function processLabel(processType: number): string {
return PROCESS_TYPE_LABEL[processType] ?? `0x${processType.toString(16).toUpperCase().padStart(2, "0")}`;
}
function HistoryRow({ entry, index }: { entry: FelicaHistoryEntry; index: number }) {
function stationLabel(regionCode: number, lineCode: number, stationCode: number): string {
if (lineCode === 0 && stationCode === 0) return "";
const entry = lookupFelicaStation(regionCode, lineCode, stationCode);
if (entry) return entry.s;
return `L${lineCode} S${stationCode}`;
}
function HistoryRow({ entry, index, prevBalance }: { entry: FelicaHistoryEntry; index: number; prevBalance: number | null }) {
const dateStr = `${entry.year}/${String(entry.month).padStart(2, "0")}/${String(entry.day).padStart(2, "0")}`;
const entryTime = formatTime(entry.entryHour, entry.entryMinute);
const exitTime = formatTime(entry.exitHour, entry.exitMinute);
const label = processLabel(entry.processType);
const regionCode = entry.regionCode ?? 0;
// 物販・チャージ・POS端末などの場合は byte[6-9] が駅コードではないので表示しない
const isTransit =
!NON_TRANSIT_PROCESS_TYPES.has(entry.processType) &&
!NON_TRANSIT_TERMINAL_TYPES.has(entry.terminalType);
const inStationLabel = isTransit ? stationLabel(regionCode, entry.inLineCode, entry.inStationCode) : "";
const outStationLabel = isTransit ? stationLabel(regionCode, entry.outLineCode, entry.outStationCode) : "";
const showStations = inStationLabel !== "" || outStationLabel !== "";
// 支払い金額 = 前の残高 - この取引後の残高(チャージは負→+表示)
const amount = prevBalance != null ? prevBalance - entry.balance : null;
const amountText = amount == null
? `残高 ¥${entry.balance.toLocaleString()}`
: amount > 0
? `${amount.toLocaleString()}`
: amount < 0
? `${(-amount).toLocaleString()}`
: "¥0";
const amountColor = amount == null ? "#999" : amount < 0 ? "#00897B" : "#0099CC";
const debugText = JSON.stringify({ ...entry, inStationLabel, outStationLabel }, null, 2);
return (
<View style={[styles.row, index % 2 === 0 ? styles.rowEven : styles.rowOdd]}>
<TouchableOpacity
onLongPress={() => {
Clipboard.setStringAsync(debugText);
}}
style={[styles.row, index % 2 === 0 ? styles.rowEven : styles.rowOdd]}
activeOpacity={0.7}
>
<View style={styles.rowLeft}>
<Text style={styles.dateText}>{dateStr}</Text>
<Text style={styles.labelText}>{label}</Text>
{entry.inStationCode !== 0 || entry.outStationCode !== 0 ? (
{showStations ? (
<Text style={styles.stationText}>
{`No.${entry.inStationCode} → No.${entry.outStationCode}`}
{outStationLabel
? `${inStationLabel}${outStationLabel}`
: inStationLabel}
</Text>
) : null}
<Text style={styles.timeText}>{entryTime} {exitTime}</Text>
</View>
<View style={styles.rowRight}>
<Text style={styles.balanceText}>
¥{entry.balance.toLocaleString()}
<Text style={[styles.amountText, { color: amountColor }]}>
{amountText}
</Text>
</View>
</View>
</TouchableOpacity>
);
}
@@ -75,6 +154,25 @@ export function FelicaHistoryPage() {
const data = await ExpoFelicaReader.scan();
console.log("NFC Scan Result:", data);
setResult(data);
if (data.balance >= 0) {
await AS.setItem(STORAGE_KEYS.FELICA_LAST_SNAPSHOT, {
balance: data.balance,
idm: data.idm,
systemCode: data.systemCode,
scannedAt: new Date().toLocaleString("ja-JP"),
});
}
if (Platform.OS === "android") {
await requestWidgetUpdate({
widgetName: "JR_shikoku_apps_shortcut",
renderWidget: async () => {
const quickData = await getFelicaQuickAccessData();
return <FelicaQuickAccessWidget {...quickData} />;
},
});
}
} catch (e: any) {
setError(e?.message ?? "読み取りに失敗しました");
} finally {
@@ -82,31 +180,23 @@ export function FelicaHistoryPage() {
}
};
// ページを開いたら自動でスキャン待機を開始
useEffect(() => {
handleScan();
}, []);
return (
<View style={styles.container}>
<SheetHeaderItem
title="ICカード残高・履歴"
LeftItem={{ title: " 設定", onPress: goBack, position: "left" }}
/>
<SheetHeaderItem title="ICカード残高・履歴" />
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
{/* スキャンボタン */}
<TouchableOpacity
style={[styles.scanButton, scanning && styles.scanButtonDisabled]}
onPress={handleScan}
disabled={scanning}
activeOpacity={0.7}
>
{scanning ? (
<>
<ActivityIndicator color="white" style={{ marginBottom: 6 }} />
<Text style={styles.scanButtonText}>
{Platform.OS === "ios" ? "カードをかざしてください…" : "スキャン中…"}
</Text>
</>
) : (
<Text style={styles.scanButtonText}>NFCカードをスキャン</Text>
)}
</TouchableOpacity>
{/* スキャン */}
{scanning && (
<View style={styles.scanningBox}>
<MaterialCommunityIcons name="nfc-search-variant" size={72} color="#0099CC" style={{ marginBottom: 12 }} />
<ActivityIndicator color="#0099CC" size="small" style={{ marginBottom: 8 }} />
<Text style={styles.scanningText}>ICカードをかざしてください</Text>
</View>
)}
{/* エラー */}
{error && (
@@ -120,6 +210,7 @@ export function FelicaHistoryPage() {
<>
{/* 残高カード */}
<View style={styles.balanceCard}>
<Text style={styles.cardTypeText}>{cardTypeLabel(result.idm)}</Text>
<Text style={styles.balanceLabel}></Text>
<Text style={styles.balanceAmount}>
{result.balance >= 0 ? `¥${result.balance.toLocaleString()}` : "読み取り失敗"}
@@ -135,12 +226,23 @@ export function FelicaHistoryPage() {
<Text style={styles.emptyText}></Text>
) : (
result.history.map((entry, i) => (
<HistoryRow key={i} entry={entry} index={i} />
<HistoryRow
key={i}
entry={entry}
index={i}
prevBalance={result.history[i + 1]?.balance ?? null}
/>
))
)}
</>
)}
</ScrollView>
{/* 下部ボタン */}
<BigButton string={scanning ? "スキャン中…" : "再スキャン"} onPress={handleScan} style={{ opacity: scanning ? 0.5 : 1 }}>
<MaterialCommunityIcons name="contactless-payment" color="white" size={28} style={{ marginRight: 8 }} />
</BigButton>
<BigButton string="閉じる" onPress={goBack} />
</View>
);
}
@@ -150,15 +252,12 @@ const styles = StyleSheet.create({
scroll: { flex: 1, backgroundColor: "white" },
scrollContent: { paddingBottom: 32 },
scanButton: {
scanningBox: {
margin: 16,
paddingVertical: 18,
borderRadius: 10,
backgroundColor: "#0099CC",
paddingVertical: 32,
alignItems: "center",
},
scanButtonDisabled: { backgroundColor: "#88ccee" },
scanButtonText: { color: "white", fontSize: 18, fontWeight: "bold" },
scanningText: { fontSize: 18, fontWeight: "bold", color: "#0099CC" },
errorBox: {
margin: 16,
@@ -179,6 +278,7 @@ const styles = StyleSheet.create({
borderColor: "#0099CC",
alignItems: "center",
},
cardTypeText: { fontSize: 13, color: "#0099CC", fontWeight: "bold", marginBottom: 8 },
balanceLabel: { fontSize: 14, color: "#555", marginBottom: 4 },
balanceAmount: { fontSize: 36, fontWeight: "bold", color: "#0099CC" },
idmText: { fontSize: 11, color: "#888", marginTop: 6 },
@@ -210,5 +310,5 @@ const styles = StyleSheet.create({
labelText: { fontSize: 15, fontWeight: "bold", color: "#333", marginTop: 2 },
stationText: { fontSize: 12, color: "#777", marginTop: 1 },
timeText: { fontSize: 12, color: "#999", marginTop: 1 },
balanceText: { fontSize: 16, fontWeight: "bold", color: "#0099CC" },
amountText: { fontSize: 16, fontWeight: "bold" },
});

View File

@@ -1,4 +1,4 @@
import React from "react";
import React, { useCallback, useEffect, useState } from "react";
import {
View,
Text,
@@ -17,8 +17,15 @@ import { SwitchArea } from "../atom/SwitchArea";
import { useNotification } from "../../stateBox/useNotifications";
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
import { useTrainMenu } from "../../stateBox/useTrainMenu";
import { Asset } from "expo-asset";
import {
useAudioPlayer,
setAudioModeAsync,
} from "expo-audio";
import type { AudioSource } from "expo-audio";
const versionCode = "6.2.1.1"; // Update this version code as needed
const settingsPreviewSound = require("../../assets/sound/rikka-test.mp3");
export const SettingTopPage = ({
testNFC,
@@ -30,6 +37,62 @@ export const SettingTopPage = ({
const { expoPushToken } = useNotification();
const { updatePermission, dataSourcePermission } = useTrainMenu();
const navigation = useNavigation();
// expo-asset でローカルパスを取得し、expo-audio に渡す
// (SDK 52 の expo-audio は file:// URI を正しく処理できないため prefix を除去)
const [resolvedSource, setResolvedSource] = useState<AudioSource>(null);
useEffect(() => {
let mounted = true;
const resolve = async () => {
try {
const asset = Asset.fromModule(settingsPreviewSound);
await asset.downloadAsync();
const localUri = asset.localUri;
if (!mounted) return;
// file:// を剥がしてパスだけにする
// (expo-audio ネイティブ AudioModule.kt の File(uri) バグ回避)
const strippedPath = localUri
? localUri.replace(/^file:\/\//, "")
: null;
if (strippedPath) {
setResolvedSource({ uri: strippedPath });
}
} catch (error) {
if (!mounted) return;
console.warn("Failed to resolve audio asset", error);
}
};
resolve();
return () => {
mounted = false;
};
}, []);
const previewPlayer = useAudioPlayer(resolvedSource);
const onPressHeaderImage = useCallback(async () => {
try {
if (Platform.OS === "ios") {
await setAudioModeAsync({
playsInSilentMode: false,
shouldPlayInBackground: false,
interruptionMode: "mixWithOthers",
});
}
if (previewPlayer.playing) previewPlayer.pause();
previewPlayer.volume = 1;
await previewPlayer.seekTo(0);
previewPlayer.play();
} catch (error) {
console.warn("Failed to play preview sound", error);
}
}, [previewPlayer]);
// admin またはいずれかのソース権限を持つ場合のみ表示
const canAccessDataSourceSettings =
updatePermission || Object.values(dataSourcePermission).some(Boolean);
@@ -42,15 +105,17 @@ export const SettingTopPage = ({
<ScrollView style={{ flex: 1, backgroundColor: "#f8f8fc" }}>
<View style={{ height: 300, padding: 10 }}>
<View style={{ flex: 1 }} />
<Image
source={require("../../assets/Header.png")}
style={{
aspectRatio: 8.08,
height: undefined,
width: width - 20,
borderRadius: 5,
}}
/>
<TouchableOpacity activeOpacity={0.9} onPress={onPressHeaderImage}>
<Image
source={require("../../assets/Header.png")}
style={{
aspectRatio: 8.08,
height: undefined,
width: width - 20,
borderRadius: 5,
}}
/>
</TouchableOpacity>
<View style={{ flexDirection: "row", paddingTop: 10 }}>
<View style={{ flex: 1 }} />
<Text>: {versionCode}</Text>

View File

@@ -58,6 +58,16 @@ export default function Setting(props) {
console.log("NFC Scan Result:", x);
return x;
});
if (result.balance >= 0) {
await AS.setItem(STORAGE_KEYS.FELICA_LAST_SNAPSHOT, {
balance: result.balance,
idm: result.idm,
systemCode: result.systemCode,
scannedAt: new Date().toLocaleString("ja-JP"),
});
}
console.log("NFC Result:", result);
alert(`IDm: ${result.idm}\n残高: ${result.balance}\nシステムコード: ${result.systemCode}`);
};
@@ -75,7 +85,7 @@ export default function Setting(props) {
]).then(() => Updates.reloadAsync());
};
return (
<Stack.Navigator>
<Stack.Navigator id={null}>
<Stack.Screen
name="settingTopPage"
options={{

View File

@@ -2,6 +2,8 @@ import { FC } from "react";
import { ListViewItem } from "@/components/StationDiagram/ListViewItem";
import { View, Text, ScrollView } from "react-native";
import dayjs from "dayjs";
import { useUnyohub } from "@/stateBox/useUnyohub";
import { useElesite } from "@/stateBox/useElesite";
type hoge = {
trainNumber: string;
array: string;
@@ -11,7 +13,11 @@ type hoge = {
};
export const ListView: FC<{
data: hoge[];
}> = ({ data }) => {
showVehicle?: boolean;
visibleSources?: { app: boolean; hub: boolean; elesite: boolean };
}> = ({ data, showVehicle = false, visibleSources = { app: true, hub: true, elesite: true } }) => {
const { getUnyohubByTrainNumber } = useUnyohub();
const { getElesiteByTrainNumber } = useElesite();
const groupedData: Record<string, hoge[]> = {};
const groupKeys = [];
data.forEach((item) => {
@@ -35,7 +41,14 @@ export const ListView: FC<{
</View>,
<View>
{groupedData[hour].map((d, i) => (
<ListViewItem key={d.trainNumber + i} d={d} />
<ListViewItem
key={d.trainNumber + i}
d={d}
showVehicle={showVehicle}
getUnyohubByTrainNumber={visibleSources.hub ? getUnyohubByTrainNumber : undefined}
getElesiteByTrainNumber={visibleSources.elesite ? getElesiteByTrainNumber : undefined}
showAppSource={visibleSources.app}
/>
))}
</View>,
])}

View File

@@ -2,8 +2,8 @@ import { migrateTrainName } from "@/lib/eachTrainInfoCoreLib/migrateTrainName";
import { getStringConfig } from "@/lib/getStringConfig";
import { getTrainType } from "@/lib/getTrainType";
import { useAllTrainDiagram } from "@/stateBox/useAllTrainDiagram";
import { FC, useEffect, useMemo, useState } from "react";
import { View, Text, TouchableOpacity } from "react-native";
import { FC, useEffect, useMemo, useRef, useState } from "react";
import { View, Text, TouchableOpacity, Animated } from "react-native";
import { customTrainDataDetector } from "../custom-train-data";
import dayjs from "dayjs";
import { SheetManager } from "react-native-actions-sheet";
@@ -23,8 +23,12 @@ export const ListViewItem: FC<{
timeType: string;
time: string;
};
}> = ({ d }) => {
const { allCustomTrainData } = useAllTrainDiagram();
showVehicle?: boolean;
showAppSource?: boolean;
getUnyohubByTrainNumber?: (trainNumber: string) => string | null;
getElesiteByTrainNumber?: (trainNumber: string) => string | null;
}> = ({ d, showVehicle = false, showAppSource = true, getUnyohubByTrainNumber, getElesiteByTrainNumber }) => {
const { allCustomTrainData, getTodayOperationByTrainId } = useAllTrainDiagram();
const { navigate } = useNavigation();
const [trainData, setTrainData] = useState<CustomTrainData | undefined>();
useEffect(() => {
@@ -199,6 +203,47 @@ export const ListViewItem: FC<{
// 運休判定
const isCancelled = d.timeType?.includes("休");
// 車番ソース一覧を構築showVehicle=trueの時のみ計算
const vehicleSources = useMemo(() => {
if (!showVehicle) return [];
const sources: Array<{ label: string; text: string; badgeColor: string; textColor: string }> = [];
if (showAppSource) {
const ops = getTodayOperationByTrainId(d.trainNumber);
const unitIds = [...new Set(ops.flatMap((op) => op.unit_ids ?? []))];
if (unitIds.length > 0) {
sources.push({ label: "運用", text: unitIds.join("・"), badgeColor: "#00BCD4", textColor: "#006064" });
}
}
const unyoText = getUnyohubByTrainNumber?.(d.trainNumber);
if (unyoText) {
sources.push({ label: "Hub", text: unyoText, badgeColor: "#9E9E9E", textColor: "#424242" });
}
const elesiteText = getElesiteByTrainNumber?.(d.trainNumber);
if (elesiteText) {
sources.push({ label: "えれ", text: elesiteText, badgeColor: "#4CAF50", textColor: "#1B5E20" });
}
return sources;
}, [showVehicle, showAppSource, d.trainNumber, getTodayOperationByTrainId, getUnyohubByTrainNumber, getElesiteByTrainNumber]);
const [sourceIndex, setSourceIndex] = useState(0);
const fadeAnim = useRef(new Animated.Value(1)).current;
useEffect(() => {
if (!showVehicle || vehicleSources.length <= 1) return;
const cycle = setInterval(() => {
Animated.timing(fadeAnim, { toValue: 0, duration: 300, useNativeDriver: true }).start(() => {
setSourceIndex((i) => (i + 1) % vehicleSources.length);
Animated.timing(fadeAnim, { toValue: 1, duration: 300, useNativeDriver: true }).start();
});
}, 3000);
return () => clearInterval(cycle);
}, [showVehicle, vehicleSources.length]);
useEffect(() => {
setSourceIndex(0);
fadeAnim.setValue(1);
}, [showVehicle, vehicleSources.length]);
return (
<TouchableOpacity
@@ -247,31 +292,54 @@ export const ListViewItem: FC<{
>
{typeString}
</Text>
<Text
style={{
fontSize: 15,
fontWeight: "bold",
flex: 1,
paddingLeft: 2,
color: isCancelled ? "gray" : color,
textDecorationLine: isCancelled ? "line-through" : "none",
}}
>
{(trainData?.train_name || "") +
((trainData?.train_num_distance !== "" && !isNaN(parseInt(trainData?.train_num_distance)))
? ` ${parseInt(d.trainNumber) - parseInt(trainData?.train_num_distance)}`
: "")}
</Text>
<Text
style={{
fontSize: 15,
fontWeight: "bold",
color: isCancelled ? "gray" : "black",
textDecorationLine: isCancelled ? "line-through" : "none",
}}
>
{trainData?.train_id}
</Text>
{showVehicle
? (() => {
if (vehicleSources.length === 0) {
return <Text style={{ fontSize: 15, color: "gray", paddingLeft: 4, flex: 1 }}></Text>;
}
const src = vehicleSources[sourceIndex % vehicleSources.length];
return (
<Animated.View style={{ flex: 1, flexDirection: "row", alignItems: "center", paddingLeft: 4, gap: 4, opacity: fadeAnim }}>
<View style={{ backgroundColor: src.badgeColor, borderRadius: 4, paddingHorizontal: 5, paddingVertical: 1 }}>
<Text style={{ fontSize: 10, color: "white", fontWeight: "bold" }}>{src.label}</Text>
</View>
<Text style={{ fontSize: 17, fontWeight: "bold", color: src.textColor, flex: 1 }} numberOfLines={1}>
{src.text}
</Text>
{vehicleSources.length > 1 && (
<Text style={{ fontSize: 10, color: "#aaa" }}>{(sourceIndex % vehicleSources.length) + 1}/{vehicleSources.length}</Text>
)}
</Animated.View>
);
})()
: <>
<Text
style={{
fontSize: 15,
fontWeight: "bold",
flex: 1,
paddingLeft: 2,
color: isCancelled ? "gray" : color,
textDecorationLine: isCancelled ? "line-through" : "none",
}}
>
{(trainData?.train_name || "") +
((trainData?.train_num_distance !== "" && !isNaN(parseInt(trainData?.train_num_distance)))
? ` ${parseInt(d.trainNumber) - parseInt(trainData?.train_num_distance)}`
: "")}
</Text>
<Text
style={{
fontSize: 15,
fontWeight: "bold",
color: isCancelled ? "gray" : "black",
textDecorationLine: isCancelled ? "line-through" : "none",
}}
>
{trainData?.train_id}
</Text>
</>
}
</View>
<View style={{ flexDirection: "row", alignItems: "center", flex: 1 }}>
<Text

View File

@@ -9,10 +9,14 @@ import {
Platform,
TouchableOpacity,
LayoutAnimation,
Modal,
Pressable,
} from "react-native";
import { useNavigation } from "@react-navigation/native";
import { BigButton } from "../atom/BigButton";
import { useAllTrainDiagram } from "@/stateBox/useAllTrainDiagram";
import { useUnyohub } from "@/stateBox/useUnyohub";
import { useElesite } from "@/stateBox/useElesite";
import { ListView } from "@/components/StationDiagram/ListView";
import dayjs from "dayjs";
import { ExGridView } from "./ExGridView";
@@ -52,7 +56,9 @@ export const StationDiagramView: FC<props> = ({ route }) => {
// 表示モード:縦並びリスト、横並びグリッド(時刻分割)、横並び単純左詰め
// フィルタリング:終点路線、種別、行先、関係停車駅
const { keyList, allTrainDiagram, allCustomTrainData } = useAllTrainDiagram();
const { keyList, allTrainDiagram, allCustomTrainData, todayOperation } = useAllTrainDiagram();
const { useUnyohub: unyohubEnabled } = useUnyohub();
const { useElesite: elesiteEnabled } = useElesite();
const { goBack } = useNavigation();
const [keyBoardVisible, setKeyBoardVisible] = useState(false);
@@ -78,6 +84,10 @@ export const StationDiagramView: FC<props> = ({ route }) => {
const [showTypeFiltering, setShowTypeFiltering] = useState(false);
const [showLastStop, setShowLastStop] = useState(false);
const [threw, setIsThrew] = useState(false);
const [showVehicle, setShowVehicle] = useState(false);
const [sourceModalVisible, setSourceModalVisible] = useState(false);
// 有効なソースのうち表示するソース(初期=全て表示)
const [visibleSources, setVisibleSources] = useState<{ app: boolean; hub: boolean; elesite: boolean }>({ app: true, hub: true, elesite: true });
const [currentStationDiagram, setCurrentStationDiagram] = useState<hoge>([]);
useEffect(() => {
if (allTrainDiagram && currentStation.length > 0) {
@@ -201,19 +211,73 @@ export const StationDiagramView: FC<props> = ({ route }) => {
}, []);
return (
<View style={{ height: "100%", backgroundColor: "#0099CC" }}>
<Text
style={{
textAlign: "center",
fontSize: 20,
color: "white",
fontWeight: "bold",
paddingVertical: 10,
}}
>
{currentStation[0].Station_JP}
</Text>
<View style={{ flexDirection: "row", alignItems: "center", paddingVertical: 10, paddingHorizontal: 10 }}>
<Text
style={{
flex: 1,
textAlign: "center",
fontSize: 20,
color: "white",
fontWeight: "bold",
}}
>
{currentStation[0].Station_JP}
</Text>
<TouchableOpacity
onPress={() => setShowVehicle((v) => !v)}
onLongPress={() => setSourceModalVisible(true)}
delayLongPress={400}
style={{
backgroundColor: showVehicle ? "white" : "rgba(255,255,255,0.3)",
borderRadius: 6,
paddingHorizontal: 8,
paddingVertical: 4,
}}
>
<Text style={{ fontSize: 18 }}>🚃</Text>
</TouchableOpacity>
</View>
{/* ソース選択モーダル */}
<Modal transparent animationType="fade" visible={sourceModalVisible} onRequestClose={() => setSourceModalVisible(false)}>
<Pressable style={{ flex: 1, backgroundColor: "rgba(0,0,0,0.4)", justifyContent: "center", alignItems: "center" }} onPress={() => setSourceModalVisible(false)}>
<Pressable style={{ backgroundColor: "white", borderRadius: 12, padding: 20, width: 280 }} onPress={() => {}}>
<Text style={{ fontSize: 16, fontWeight: "bold", marginBottom: 14, textAlign: "center" }}></Text>
{[
{ key: "app" as const, label: "🚃 アプリ運用情報", color: "#00BCD4", available: todayOperation.length > 0 },
{ key: "hub" as const, label: "🚃 鉄道運用Hub", color: "#9E9E9E", available: unyohubEnabled },
{ key: "elesite" as const, label: "🚃 えれサイト", color: "#4CAF50", available: elesiteEnabled },
].map(({ key, label, color, available }) => (
<TouchableOpacity
key={key}
onPress={() => available && setVisibleSources((s) => ({ ...s, [key]: !s[key] }))}
style={{
flexDirection: "row",
alignItems: "center",
paddingVertical: 10,
opacity: available ? 1 : 0.35,
}}
>
<View style={{
width: 22, height: 22, borderRadius: 4, borderWidth: 2,
borderColor: available ? color : "#ccc",
backgroundColor: visibleSources[key] && available ? color : "transparent",
marginRight: 12, alignItems: "center", justifyContent: "center",
}}>
{visibleSources[key] && available && <Text style={{ color: "white", fontSize: 14, fontWeight: "bold" }}></Text>}
</View>
<Text style={{ fontSize: 15, color: available ? "#222" : "#aaa", flex: 1 }}>{label}</Text>
{!available && <Text style={{ fontSize: 11, color: "#bbb" }}></Text>}
</TouchableOpacity>
))}
<TouchableOpacity onPress={() => setSourceModalVisible(false)} style={{ marginTop: 8, backgroundColor: "#0099CC", borderRadius: 8, paddingVertical: 10, alignItems: "center" }}>
<Text style={{ color: "white", fontWeight: "bold", fontSize: 15 }}></Text>
</TouchableOpacity>
</Pressable>
</Pressable>
</Modal>
{displayMode === "list" ? (
<ListView data={currentStationDiagram} />
<ListView data={currentStationDiagram} showVehicle={showVehicle} visibleSources={visibleSources} />
) : displayMode === "simpleGrid" ? (
<ExGridSimpleView
data={currentStationDiagram}

View File

@@ -51,6 +51,7 @@ export const ScrollingDescription: FC<Props> = ({ description }) => {
return () => {
animation.stop();
scrollX.stopAnimation();
scrollX.setValue(containerWidth);
};
}, [singleLineDescription, textWidth, containerWidth]);
@@ -58,14 +59,14 @@ export const ScrollingDescription: FC<Props> = ({ description }) => {
const handleTextLayout = (event: LayoutChangeEvent) => {
const { width } = event.nativeEvent.layout;
if (width > 0) {
setTextWidth(width);
setTextWidth(Math.round(width));
}
};
const handleContainerLayout = (event: LayoutChangeEvent) => {
const { width } = event.nativeEvent.layout;
if (width > 0) {
setContainerWidth(width);
setContainerWidth(Math.round(width));
}
};

View File

@@ -93,6 +93,9 @@ export const STORAGE_KEYS = {
/** えれサイトデータ */
ELESITE_DATA: 'elesiteData',
/** Felica最終読取スナップショット */
FELICA_LAST_SNAPSHOT: 'felicaLastSnapshot',
} as const;
/**

View File

@@ -271,8 +271,8 @@
{"3108M":"高松,発,6:46#坂出,発,7:01#児島,発,7:17#茶屋町,発,7:27#早島,発,7:30#妹尾,発,7:34#備前西市,発,7:39#大元,発,7:43#岡山,着,7:46#"},
{"3110M":"高松,発,7:08#坂出,発,7:23#児島,発,7:39#上の町,発,7:43#木見,発,7:47#植松,発,7:50#茶屋町,発,7:54#早島,発,7:58#妹尾,発,8:02#備前西市,発,8:08#大元,発,8:12#岡山,着,8:15#"},
{"3112M":"高松,発,7:48#坂出,発,8:03#児島,発,8:21#茶屋町,発,8:30#早島,発,8:34#妹尾,発,8:38#岡山,着,8:45#"},
{"3114M":"高松,発,8:22#坂出,発,8:36#児島,発,8:53#茶屋町,発,9:02#早島,発,9:06#妹尾,発,9:10#備前西市,発,9:14#岡山,着,9:19#"},
{"3116M":"高松,発,8:55#坂出,発,9:09#児島,発,9:25#茶屋町,発,9:33#早島,発,9:36#妹尾,発,9:40#岡山,着,9:47#"},
{"3114M":"高松,発,8:22#坂出,発,8:36#児島,発,8:53#茶屋町,発,9:02#早島,発,9:06#妹尾,発,9:10#岡山,着,9:19#"},
{"3116M":"高松,発,8:55#坂出,発,9:09#児島,発,9:25#茶屋町,発,9:33#早島,発,9:37#妹尾,発,9:40#岡山,着,9:47#"},
{"3118M":"高松,発,9:23#坂出,発,9:38#児島,発,9:54#茶屋町,発,10:03#妹尾,発,10:10#岡山,着,10:17#"},
{"3120M":"高松,発,9:52#坂出,発,10:07#児島,発,10:23#茶屋町,発,10:33#早島,発,10:37#岡山,着,10:48#"},
{"3122M":"高松,発,10:10#坂出,発,10:24#児島,発,10:40#茶屋町,発,10:49#妹尾,発,10:56#岡山,着,11:03#"},
@@ -299,7 +299,7 @@
{"3164M":"高松,発,20:43#坂出,発,20:57#児島,発,21:13#茶屋町,発,21:22#早島,発,21:25#岡山,着,21:36#"},
{"3166M":"高松,発,21:13#坂出,発,21:27#児島,発,21:44#茶屋町,発,21:52#妹尾,発,21:58#岡山,着,22:05#"},
{"3168M":"高松,発,21:43#坂出,発,21:57#児島,発,22:14#茶屋町,発,22:22#早島,発,22:26#妹尾,発,22:31#岡山,着,22:38#"},
{"3170M":"高松,発,22:27#端岡,発,22:35#鴨川,発,22:41#坂出,発,22:45#児島,発,23:01#上の町,発,23:04#木見,発,23:08#植松,発,23:11#茶屋町,発,23:15#早島,発,23:18#妹尾,発,23:21#大元,発,23:26#岡山,着,23:30#"},
{"3170M":"高松,発,22:27#端岡,発,22:35#鴨川,発,22:41#坂出,発,22:45#児島,発,23:01#上の町,発,23:04#木見,発,23:08#植松,発,23:11#茶屋町,発,23:15#早島,発,23:18#妹尾,発,23:21#大元,発,23:27#岡山,着,23:30#"},
{"5032M":"高松,発,21:26#坂出,発,21:44#児島,発,22:01#岡山,着,22:23#"},
{"8176D":"高松,発,9:13#端岡,発,9:24#鴨川,発,9:36#坂出,発,9:43#児島,発,10:15#岡山,着,10:44#"},
{"3101M":"岡山,発,5:27#大元,発,5:30#妹尾,発,5:35#早島,発,5:39#茶屋町,発,5:42#植松,発,5:45#木見,発,5:48#上の町,発,5:52#児島,発,5:57#坂出,発,6:13#高松,着,6:31#"},
@@ -335,7 +335,7 @@
{"3161M":"岡山,発,20:13#妹尾,発,20:21#茶屋町,発,20:27#児島,発,20:36#坂出,発,20:52#高松,着,21:07#"},
{"3163M":"岡山,発,20:42#妹尾,発,20:52#早島,発,20:56#茶屋町,発,20:59#児島,発,21:08#坂出,発,21:24#高松,着,21:39#"},
{"3165M":"岡山,発,21:13#妹尾,発,21:23#茶屋町,発,21:29#児島,発,21:38#坂出,発,21:53#高松,着,22:08#"},
{"3167M":"岡山,発,21:42#妹尾,発,21:52#早島,発,21:56#茶屋町,発,21:59#児島,発,22:09#坂出,発,22:24#高松,着,22:39#"},
{"3167M":"岡山,発,21:42#妹尾,発,21:52#早島,発,21:55#茶屋町,発,21:59#児島,発,22:09#坂出,発,22:24#高松,着,22:39#"},
{"3169M":"岡山,発,22:12#妹尾,発,22:23#早島,発,22:27#茶屋町,発,22:30#児島,発,22:40#坂出,発,22:55#高松,着,23:10#"},
{"3171M":"岡山,発,22:46#妹尾,発,22:53#早島,発,22:57#茶屋町,発,23:00#上の町,発,23:07#児島,発,23:11#坂出,発,23:27#鴨川,発,23:31#国分,発,23:36#端岡,発,23:39#鬼無,発,23:42#高松,着,23:48#"},
{"3173M":"岡山,発,23:12#大元,発,23:16#妹尾,発,23:22#早島,発,23:25#茶屋町,発,23:29#植松,発,23:32#木見,発,23:35#上の町,発,23:39#児島,発,23:43#坂出,発,23:59#鴨川,発,0:04#国分,発,0:08#端岡,発,0:11#鬼無,発,0:15#高松,着,0:20#"},
@@ -390,7 +390,7 @@
{"29M":"岡山,発,22:00#児島,発,22:22#宇多津,発,22:36#丸亀,発,22:39#多度津,発,22:52#詫間,発,23:00#高瀬,発,23:05#観音寺,発,23:13#川之江,発,23:30#伊予三島,発,23:35#新居浜,発,23:52#伊予西条,着,23:59#"},
{"31D":"岡山,発,7:08#児島,発,7:29#宇多津,発,7:46#丸亀,発,7:50#多度津,発,7:56#善通寺,発,8:01#琴平,発,8:06#阿波池田,発,8:29#大歩危,発,8:49#大杉,発,9:07#土佐山田,発,9:27#後免,発,9:32#高知,着,9:39#"},
{"33D":"岡山,発,8:52#児島,発,9:17#宇多津,発,9:33#丸亀,発,9:36#多度津,発,9:44#善通寺,発,9:51#琴平,発,9:59#阿波池田,発,10:24#大歩危,発,10:41#大杉,発,10:58#土佐山田,発,11:18#後免,発,11:23#高知,着,11:30#"},
{"35D":"岡山,発,10:05#児島,発,10:26#宇多津,発,10:39#丸亀,発,10:43#多度津,発,10:48#善通寺,発,10:54#琴平,発,10:59#阿波池田,発,11:24#大歩危,発,11:41#土佐山田,発,12:16#後免,発,12:21#高知,着,12:29#"},
{"35D":"岡山,発,10:05#児島,発,10:26#宇多津,発,10:40#丸亀,発,10:43#多度津,発,10:48#善通寺,発,10:54#琴平,発,10:59#阿波池田,発,11:24#大歩危,発,11:41#土佐山田,発,12:16#後免,発,12:21#高知,着,12:29#"},
{"37D":"岡山,発,11:05#児島,発,11:25#宇多津,発,11:39#丸亀,発,11:42#多度津,発,11:47#善通寺,発,11:53#琴平,発,11:59#阿波池田,発,12:24#大歩危,発,12:46#土佐山田,発,13:26#後免,発,13:31#高知,着,13:39#"},
{"39D":"岡山,発,12:05#児島,発,12:25#宇多津,発,12:39#丸亀,発,12:42#多度津,発,12:47#善通寺,発,12:53#琴平,発,12:59#阿波池田,発,13:24#大歩危,発,13:44#土佐山田,発,14:26#後免,発,14:31#高知,着,14:38#"},
{"41D":"岡山,発,13:05#児島,発,13:25#宇多津,発,13:39#丸亀,発,13:42#多度津,発,13:47#善通寺,発,13:53#琴平,発,13:59#阿波池田,発,14:24#大歩危,発,14:42#土佐山田,発,15:26#後免,発,15:31#高知,着,15:38#"},
@@ -416,7 +416,7 @@
{"3813D":"江川崎,発,7:00#西ケ方,発,7:04#真土,発,7:13#吉野生,発,7:16#松丸,発,7:21#出目,発,7:27#近永,発,7:31#深田,発,7:35#大内,発,7:41#二名,発,7:44#伊予宮野下,発,7:49#務田,発,7:51#北宇和島,発,8:05#宇和島,着,8:07#"},
{"4811D":"窪川,発,5:50#若井,発,5:55#家地川,発,6:02#打井川,発,6:08#土佐大正,発,6:15#土佐昭和,発,6:24#十川,発,6:29#半家,発,6:37#江川崎,着,6:45#"},
{"4815D":"江川崎,発,9:00#西ケ方,発,9:04#真土,発,9:13#吉野生,発,9:16#松丸,発,9:21#出目,発,9:27#近永,発,9:31#深田,発,9:35#大内,発,9:41#二名,発,9:44#伊予宮野下,発,9:53#務田,発,9:55#北宇和島,発,10:08#宇和島,着,10:11#"},
{"4817D":"窪川,発,9:39#若井,発,9:45#家地川,発,9:53#打井川,発,9:59#土佐大正,発,10:10#土佐昭和,発,10:18#十川,発,10:23#半家,発,10:31#江川崎,発,11:00#西ケ方,発,11:04#真土,発,11:13#吉野生,発,11:16#松丸,発,11:21#出目,発,11:27#近永,発,11:31#深田,発,11:35#大内,発,11:41#二名,発,11:44#伊予宮野下,発,11:49#務田,発,11:51#北宇和島,発,12:04#宇和島,着,12:07#"},
{"4817D":"窪川,発,9:39#若井,発,9:45#家地川,発,9:53#打井川,発,9:59#土佐大正,発,10:10#土佐昭和,発,10:19#十川,発,10:24#半家,発,10:32#江川崎,発,11:00#西ケ方,発,11:04#真土,発,11:13#吉野生,発,11:16#松丸,発,11:21#出目,発,11:27#近永,発,11:31#深田,発,11:35#大内,発,11:41#二名,発,11:44#伊予宮野下,発,11:49#務田,発,11:51#北宇和島,発,12:04#宇和島,着,12:07#"},
{"4819D":"江川崎,発,13:00#西ケ方,発,13:04#真土,発,13:13#吉野生,発,13:16#松丸,発,13:21#出目,発,13:27#近永,発,13:31#深田,発,13:35#大内,発,13:41#二名,発,13:44#伊予宮野下,発,13:49#務田,発,13:51#北宇和島,発,14:04#宇和島,着,14:07#"},
{"4823D":"江川崎,発,17:00#西ケ方,発,17:04#真土,発,17:13#吉野生,発,17:16#松丸,発,17:21#出目,発,17:27#近永,発,17:31#深田,発,17:35#大内,発,17:41#二名,発,17:44#伊予宮野下,発,17:49#務田,発,17:51#北宇和島,発,18:04#宇和島,着,18:07#"},
{"4825D":"窪川,発,17:41#若井,発,17:47#家地川,発,17:55#打井川,発,18:01#土佐大正,発,18:10#土佐昭和,発,18:19#十川,発,18:25#半家,発,18:33#江川崎,発,19:00#西ケ方,発,19:04#真土,発,19:13#吉野生,発,19:16#松丸,発,19:21#出目,発,19:27#近永,発,19:31#深田,発,19:35#大内,発,19:41#二名,発,19:44#伊予宮野下,発,19:49#務田,発,19:51#北宇和島,発,20:04#宇和島,着,20:07#"},
@@ -634,7 +634,7 @@
{"6219D":"多度津,発,8:20#金蔵寺,発,8:24#善通寺,発,8:28#琴平,着,8:33#"},
{"8021D":"多度津,発,10:19#善通寺,発,10:26#琴平,発,10:48#讃岐財田,発,11:11#坪尻,発,11:34#阿波池田,発,11:55#阿波川口,発,12:14#大歩危,着,12:34#"},
{"8031M":"高松,発,8:02#多度津,発,8:26#善通寺,発,8:33#琴平,着,8:39#"},
{"8073D":"高知,発,10:02#旭,発,10:16#伊野,発,10:31#日下,発,10:50#土佐加茂,発,11:08#吾桑,発,11:31#安和,発,11:56#土佐久礼,発,12:11#窪川,着,12:32#"},
{"8073D":"高知,発,10:02#旭,発,10:16#日下,発,10:50#土佐加茂,発,11:01#多ノ郷,発,11:30#安和,発,11:51#土佐久礼,発,12:11#窪川,着,12:32#"},
{"8083D":"後免,発,17:25#土佐一宮,発,17:43#薊野,発,17:53#高知,着,17:57#"},
{"310D":"徳島,発,5:51#佐古,発,5:54#吉成,発,6:00#勝瑞,発,6:03#池谷,発,6:07#板東,発,6:10#阿波川端,発,6:14#板野,発,6:18#阿波大宮,発,6:24#讃岐相生,発,6:31#引田,発,6:35#讃岐白鳥,発,6:40#三本松,発,6:47#丹生,発,6:51#鶴羽,発,6:55#讃岐津田,発,6:59#神前,発,7:04#造田,発,7:10#オレンジタウン,発,7:14#志度,発,7:22#讃岐牟礼,発,7:25#八栗口,発,7:28#古高松南,発,7:31#屋島,発,7:34#木太町,発,7:38#栗林,発,7:41#栗林公園北口,発,7:44#昭和町,発,7:47#高松,着,7:50#"},
{"312D":"徳島,発,6:09#佐古,発,6:12#吉成,発,6:18#勝瑞,発,6:20#池谷,発,6:24#板東,発,6:28#阿波川端,発,6:32#板野,発,6:36#阿波大宮,発,6:42#讃岐相生,発,6:48#引田,発,6:53#讃岐白鳥,発,6:58#三本松,発,7:03#丹生,発,7:07#鶴羽,発,7:12#讃岐津田,発,7:16#神前,発,7:21#造田,発,7:27#オレンジタウン,発,7:33#志度,発,7:37#讃岐牟礼,発,7:41#八栗口,発,7:44#古高松南,発,7:47#屋島,発,7:51#木太町,発,7:55#栗林,発,8:00#栗林公園北口,発,8:03#昭和町,発,8:06#高松,着,8:09#"},
@@ -812,7 +812,7 @@
{"5444D":"阿波池田,発,7:52#佃,発,7:58#辻,発,8:04#阿波加茂,発,8:10#三加茂,発,8:14#江口,発,8:18#阿波半田,発,8:25#貞光,発,8:29#小島,発,8:35#穴吹,発,8:43#川田,発,8:49#阿波山川,発,8:53#山瀬,発,8:57#学,発,9:01#阿波川島,発,9:05#西麻植,発,9:09#鴨島,発,9:19#麻植塚,発,9:22#牛島,発,9:26#下浦,発,9:30#石井,発,9:33#府中,発,9:38#鮎喰,発,9:42#蔵本,発,9:45#佐古,発,9:48#徳島,着,9:51#"},
{"5462D":"阿波池田,発,12:37#佃,発,12:43#辻,発,12:48#阿波加茂,発,12:54#三加茂,発,12:57#江口,発,13:03#阿波半田,発,13:10#貞光,発,13:16#小島,発,13:22#穴吹,発,13:32#川田,発,13:38#阿波山川,発,13:42#山瀬,発,13:48#学,発,13:52#阿波川島,発,14:05#西麻植,発,14:08#鴨島,発,14:12#麻植塚,発,14:15#牛島,発,14:21#下浦,発,14:25#石井,発,14:28#府中,発,14:37#鮎喰,発,14:40#蔵本,発,14:43#佐古,発,14:46#徳島,着,14:49#"},
{"5486D":"阿波池田,発,19:51#佃,発,19:57#辻,発,20:00#阿波加茂,発,20:06#三加茂,発,20:09#江口,発,20:14#阿波半田,発,20:24#貞光,発,20:27#小島,発,20:34#穴吹,発,20:42#川田,発,20:52#阿波山川,発,20:56#山瀬,発,21:00#学,発,21:04#阿波川島,発,21:08#西麻植,発,21:12#鴨島,発,21:15#麻植塚,発,21:18#牛島,発,21:22#下浦,発,21:25#石井,発,21:29#府中,発,21:34#鮎喰,発,21:37#蔵本,発,21:40#佐古,発,21:44#徳島,着,21:46#"},
{"8452D":"阿波池田,発,14:33#阿波加茂,発,14:52#貞光,発,15:24#穴吹,発,15:43#川田,発,15:58#学,発,16:12#鴨島,発,16:29#石井,発,16:49#蔵本,発,16:58#徳島,着,17:04#"},
{"8452D":"阿波池田,発,14:33#阿波加茂,発,14:52#貞光,発,15:24#穴吹,発,15:43#川田,発,15:58#学,発,16:12#鴨島,発,16:29#石井,発,16:44#蔵本,発,16:58#徳島,着,17:04#"},
{"433D":"徳島,発,6:23#佐古,発,6:26#蔵本,発,6:29#鮎喰,発,6:32#府中,発,6:35#石井,発,6:40#下浦,発,6:43#牛島,発,6:47#麻植塚,発,6:50#鴨島,発,6:54#西麻植,発,6:57#阿波川島,発,7:00#学,発,7:05#山瀬,発,7:09#阿波山川,発,7:12#川田,発,7:16#穴吹,発,7:23#小島,発,7:29#貞光,発,7:37#阿波半田,発,7:40#江口,発,7:46#三加茂,発,7:50#阿波加茂,発,7:53#辻,発,8:01#佃,発,8:04#阿波池田,着,8:09#"},
{"439D":"徳島,発,8:12#佐古,発,8:15#蔵本,発,8:21#鮎喰,発,8:24#府中,発,8:28#石井,発,8:37#下浦,発,8:41#牛島,発,8:50#麻植塚,発,8:53#鴨島,発,8:56#西麻植,発,9:00#阿波川島,発,9:09#学,発,9:13#山瀬,発,9:17#阿波山川,発,9:21#川田,発,9:25#穴吹,着,9:30#"},
{"451D":"徳島,発,11:52#佐古,発,11:55#蔵本,発,11:58#鮎喰,発,12:00#府中,発,12:07#石井,発,12:20#下浦,発,12:23#牛島,発,12:27#麻植塚,発,12:30#鴨島,発,12:33#西麻植,発,12:36#阿波川島,発,12:39#学,発,12:44#山瀬,発,12:50#阿波山川,発,12:53#川田,発,12:57#穴吹,発,13:03#小島,発,13:09#貞光,発,13:16#阿波半田,発,13:19#江口,発,13:25#三加茂,発,13:29#阿波加茂,発,13:32#辻,発,13:37#佃,発,13:41#阿波池田,着,13:47#"},

148
docs/widget-overview.md Normal file
View File

@@ -0,0 +1,148 @@
# Android ウィジェット機能 概要
## ウィジェット一覧app.json 登録済み: 4種
| 名前 | ラベル | 概要 |
|---|---|---|
| `JR_shikoku_train_info` | 列車遅延速報EX | GAS API から遅延データ取得→表示 |
| `JR_shikoku_train_strange` | 怪レい列車 | app.json に定義あるが専用レンダリング分岐なし |
| `JR_shikoku_info` | 運行情報 | app.json に定義あり。AsyncStorage の `widgetType/{id}``Info_Widget` に手動切替可能 |
| `JR_shikoku_apps_shortcut` | クイックアクセス | FeliCa IC カード残高表示 + タップでスキャン画面へディープリンク |
全ウィジェット共通: `updatePeriodMillis: 1800000`30分`resizeMode: "horizontal|vertical"`
---
## ファイル構成
| ファイル | 役割 |
|---|---|
| `components/AndroidWidget/TraInfoEXWidget.tsx` | 遅延速報EXウィジェットUI + データ取得 |
| `components/AndroidWidget/InfoWidget.tsx` | 運行情報ウィジェットUI + データ取得 |
| `components/AndroidWidget/FelicaQuickAccessWidget.tsx` | クイックアクセスウィジェットUI + 残高データ取得 |
| `components/AndroidWidget/widget-task-handler.tsx` | ウィジェットイベントハンドラ(全ウィジェット共通) |
| `components/AndroidWidget/HelloWidgetPreviewScreen.tsx` | 死コード(`HelloWidget` が存在しない) |
| `components/Settings/WidgetSettings.tsx` | ウィジェット設定画面ウィジェットIDごとに表示タイプ切替 |
| `index.ts` | `registerWidgetTaskHandler()` でハンドラ登録Android のみ) |
| `app.json` | ウィジェットプラグイン設定 + intentFilters |
| `constants/storage.ts` | `FELICA_LAST_SNAPSHOT` キー定義 |
| `lib/rootNavigation.ts` | グローバルナビゲーション ref`createNavigationContainerRef` |
---
## ディープリンク設定
### intentFiltersapp.json
```json
[
{ "action": "VIEW", "data": [{"scheme": "jrshikoku"}],
"category": ["BROWSABLE", "DEFAULT"] },
{ "action": "VIEW",
"data": [{"scheme": "jrshikoku", "host": "open", "pathPrefix": "/felica"}],
"category": ["BROWSABLE", "DEFAULT"] }
]
```
### Linking configApps.tsx
```typescript
const linking = {
prefixes: ["jrshikoku://"],
config: {
screens: {
positions: { screens: { Apps: "positions/apps" } },
topMenu: {
screens: {
menu: "topMenu/menu",
setting: {
screens: {
settingTopPage: "topMenu/setting",
FelicaHistoryPage: "topMenu/setting/FelicaHistoryPage",
},
},
},
},
information: "information",
},
},
};
```
---
## ウィジェットタップ → Felica スキャン画面 フロー
```
FelicaQuickAccessWidget タップ
→ clickAction="OPEN_URI", uri="jrshikoku://open/felica"
→ Android OS が intentFilter マッチでアプリ起動
→ App.tsx: Linking.getInitialURL() or addEventListener("url")
→ routeFromUrl(url): "open/felica" を検知
→ openFelicaPage(): rootNavigationRef.navigate("topMenu" > "setting" > "FelicaHistoryPage")
※ nav 未 ready なら 250ms × 最大8回 retry
→ FelicaHistoryPage で NFC スキャン画面表示
→ scan() 成功後:
- UI に結果表示
- AS.setItem(FELICA_LAST_SNAPSHOT, { balance, idm, systemCode, scannedAt })
- requestWidgetUpdate("JR_shikoku_apps_shortcut") でウィジェット即時更新
```
---
## widget-task-handler イベントディスパッチ
```
widgetAction:
WIDGET_ADDED / WIDGET_UPDATE / WIDGET_CLICK / WIDGET_RESIZED
→ widgetName === "JR_shikoku_apps_shortcut"
→ getFelicaQuickAccessData() → FelicaQuickAccessWidget 描画
→ それ以外
→ AS.getItem(`widgetType/${widgetId}`) でユーザー設定判定
- "Info_Widget" → getInfoString() → InfoWidget
- "JR_shikoku_train_info" → getDelayData() → TraInfoEXWidget (デフォルト)
WIDGET_DELETED
→ AS.removeItem(`widgetType/${widgetId}`)
```
---
## 残高スナップショット保存箇所
| ファイル | トリガー | ウィジェット更新 |
|---|---|---|
| `FelicaHistoryPage.tsx` (L159) | `handleScan()` 成功 | ✅ `requestWidgetUpdate` あり |
| `settings.tsx` (L63) | `testNFC()` 成功 | ⚠️ なし(次回定期更新まで反映されない) |
保存形式: `{ balance, idm, systemCode, scannedAt }` → AsyncStorage キー `felicaLastSnapshot`
---
## 通知タップ → ナビゲーションuseNotifications.tsx
| 判定キー | アクション | ナビゲーション先 |
|---|---|---|
| `遅延速報ex`, `trainfoex`, `tra_info_ex` | `delay-ex` | topMenu/menu → ActionSheet `JRSTraInfo` |
| `怪レい列車bot`, `strange_train` | `strange-train` | positions/Apps |
| `運行情報`, `information` | `information` | information タブ |
チャンネルID / categoryIdentifier / data.category を優先判定、フォールバックで通知テキストの正規化マッチング。
---
## ネイティブモジュールexpo-felica-reader
- エクスポート: `scan()` のみ
- 戻り値: `FelicaCardInfo { idm, balance, systemCode, history[] }`
- Android: `NfcF` (FeliCa) リーダーモード、サービスコード `0x090F` で残高+履歴読取
- `launchApp` 等のネイティブヘルパーは除去済み
---
## 既知の不整合・注意点
1. **`JR_shikoku_train_strange`**: app.json に定義済みだが widget-task-handler に専用描画分岐がない(`nameToWidget` にもマッピングなし)
2. **`HelloWidgetPreviewScreen.tsx`**: `HelloWidget` を import しているが該当ファイルが存在しない(死コード)
3. **`testNFC`settings.tsx**: スナップショット保存するが `requestWidgetUpdate` を呼ばないためウィジェット未同期
4. **ディープリンク**: `intentFilters` は app.json に追加済みだが**ネイティブビルド未実施**のため未検証

5931
lib/felicaStationMap.ts Normal file

File diff suppressed because it is too large Load Diff

3
lib/rootNavigation.ts Normal file
View File

@@ -0,0 +1,3 @@
import { createNavigationContainerRef } from "@react-navigation/native";
export const rootNavigationRef = createNavigationContainerRef<any>();

View File

@@ -148,8 +148,8 @@ export const injectJavascriptData = ({
const operationListUpdate = () => {
fetch("${API_ENDPOINTS.OPERATION_LOGS}").then(r => r.json())
.then(data => {
if (data?.data === null) return;
const filtered = (data.data || []).filter(d => d.state !== 100);
const source = Array.isArray(data?.data) ? data.data : [];
const filtered = source.filter(d => d.state !== 100);
if (hasChanged('operationList', filtered)) {
operationList = filtered;
_wcache.set('operationList', filtered);
@@ -241,10 +241,10 @@ export const injectJavascriptData = ({
_hashes['trainDataList'] = JSON.stringify(trainDataList);
}).catch(() => {}),
fetch("${API_ENDPOINTS.OPERATION_LOGS}").then(r => r.json()).then(data => {
if (data?.data != null) {
operationList = (data.data || []).filter(d => d.state !== 100);
_hashes['operationList'] = JSON.stringify(operationList);
}
const source = Array.isArray(data?.data) ? data.data : [];
operationList = source.filter(d => d.state !== 100);
_hashes['operationList'] = JSON.stringify(operationList);
_wcache.set('operationList', operationList);
}).catch(() => {}),
fetch("${API_ENDPOINTS.POSITION_PROBLEMS}").then(r => r.json()).then(data => {
probremsData = data?.data ?? probremsData;
@@ -299,12 +299,11 @@ export const injectJavascriptData = ({
}).catch(() => {}),
fetch("${API_ENDPOINTS.OPERATION_LOGS}").then(r => r.json()).then(data => {
if (data?.data != null) {
const filtered = (data.data || []).filter(d => d.state !== 100);
operationList = filtered;
_hashes['operationList'] = JSON.stringify(filtered);
_wcache.set('operationList', filtered);
}
const source = Array.isArray(data?.data) ? data.data : [];
const filtered = source.filter(d => d.state !== 100);
operationList = filtered;
_hashes['operationList'] = JSON.stringify(filtered);
_wcache.set('operationList', filtered);
}).catch(() => {}),
fetch("${API_ENDPOINTS.POSITION_PROBLEMS}").then(r => r.json()).then(data => {

View File

@@ -88,9 +88,10 @@ class ExpoFelicaReaderModule : Module() {
val balanceResponse = nfcF.transceive(buildReadWithoutEncryptionCommand(idm, balanceServiceCode, listOf(0)))
val balance = parseBalance(balanceResponse)
// 利用履歴読み取り(サービスコード 0x090D, 1 ブロックずつ最大 20 件)
// 利用履歴読み取り(サービスコード 0x090F, 1 ブロックずつ最大 20 件)
// ※ 同じ 0x090F サービスを使用: ブロック 0〜19 が利用履歴Suica / PiTaPa 等共通)
// 交通系 IC カードは複数ブロック一括読み取りに対応していない場合が多いため 1 件ずつ読む
val historyServiceCode = byteArrayOf(0x0D.toByte(), 0x09.toByte())
val historyServiceCode = byteArrayOf(0x0F.toByte(), 0x09.toByte())
val history = mutableListOf<Map<String, Any>>()
for (blockNum in 0 until 20) {
try {
@@ -181,11 +182,17 @@ class ExpoFelicaReaderModule : Module() {
/**
* 利用履歴ブロック16 バイト)を Map に変換する。
* FeliCa 交通系 IC カード利用履歴フォーマット:
* [0] 端末種別, [1] 処理種別, [2-3] 処理番号,
* [4-5] 日付(上位 7bit=年-2000, 次 4bit=月, 下 5bit=日),
* [6] 入場時刻30 分単位), [7] 出場時刻30 分単位),
* [8-9] 残高 LE, [10] 入場路線, [11] 入場駅, [12] 出場路線, [13] 出場駅, [14] 会社コード
* FeliCa 交通系 IC カード利用履歴フォーマット(サービス 0x090F:
* [0] 端末種別
* [1] 処理種別
* [2-3] 処理番号
* [4-5] 日付(上位 7bit=年-2000, 次 4bit=月, 下 5bit=日)
* [6] 入場路線コード
* [7] 入場駅コード
* [8] 出場路線コード
* [9] 出場駅コード
* [10-11] 取引後残高 LE
* [13] 会社コード
*/
private fun parseHistoryBlock(block: ByteArray): Map<String, Any> {
val terminalType = block[0].toInt() and 0xFF
@@ -195,9 +202,12 @@ class ExpoFelicaReaderModule : Module() {
val year = 2000 + ((dateWord shr 9) and 0x7F)
val month = (dateWord shr 5) and 0x0F
val day = dateWord and 0x1F
val entryMinutes = (block[6].toInt() and 0xFF) * 30
val exitMinutes = (block[7].toInt() and 0xFF) * 30
val balance = (block[8].toInt() and 0xFF) or ((block[9].toInt() and 0xFF) shl 8)
val inLineCode = block[6].toInt() and 0xFF
val inStationCode = block[7].toInt() and 0xFF
val outLineCode = block[8].toInt() and 0xFF
val outStationCode = block[9].toInt() and 0xFF
val balance = (block[10].toInt() and 0xFF) or ((block[11].toInt() and 0xFF) shl 8)
val regionCode = block[15].toInt() and 0xFF
return mapOf(
"terminalType" to terminalType,
"processType" to processType,
@@ -205,16 +215,13 @@ class ExpoFelicaReaderModule : Module() {
"year" to year,
"month" to month,
"day" to day,
"entryHour" to (entryMinutes / 60),
"entryMinute" to (entryMinutes % 60),
"exitHour" to (exitMinutes / 60),
"exitMinute" to (exitMinutes % 60),
"balance" to balance,
"inLineCode" to (block[10].toInt() and 0xFF),
"inStationCode" to (block[11].toInt() and 0xFF),
"outLineCode" to (block[12].toInt() and 0xFF),
"outStationCode" to (block[13].toInt() and 0xFF),
"companyCode" to (block[14].toInt() and 0xFF)
"inLineCode" to inLineCode,
"inStationCode" to inStationCode,
"outLineCode" to outLineCode,
"outStationCode" to outStationCode,
"companyCode" to (block[13].toInt() and 0xFF),
"regionCode" to regionCode
)
}
}

View File

@@ -1,27 +1,26 @@
require 'json'
package = JSON.parse(File.read(File.join(__dir__, '../../../', 'package.json')))
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
Pod::Spec.new do |s|
s.name = 'ExpoFelicaReader'
s.version = "0.1.0"
s.summary = "A module for reading Felica cards in ExpoKit."
s.description = "Expo FeliCa reader module"
s.version = package['version']
s.summary = package['description']
s.description = package['description']
s.license = "MIT"
s.author = "Daiki Urata (https://github.com/7nohe)"
s.homepage = "https://github.com/7nohe/expo-felica-reader#readme"
s.author = "harukin"
s.homepage = "https://github.com/harukin/jrshikoku"
s.platform = :ios, '13.0'
s.swift_version = '5.4'
s.source = { git: 'https://github.com/7nohe/expo-felica-reader' }
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "**/*.{h,m,swift}"
s.source_files = "*.{h,m,swift}"
end

View File

@@ -127,7 +127,7 @@ class FelicaReaderSession: NSObject, NFCTagReaderSessionDelegate {
return
}
let historyServiceCode = Data([0x0D, 0x09])
let historyServiceCode = Data([0x0F, 0x09])
felicaTag.readWithoutEncryption(
serviceCodeList: [historyServiceCode],
@@ -150,11 +150,17 @@ class FelicaReaderSession: NSObject, NFCTagReaderSessionDelegate {
/**
* 16 Dictionary
* FeliCa IC :
* [0] , [1] , [2-3] ,
* [4-5] 7bit=-2000, 4bit=, 5bit=,
* [6] 30 , [7] 30 ,
* [8-9] LE, [10] , [11] , [12] , [13] , [14]
* FeliCa IC 0x090F:
* [0]
* [1]
* [2-3]
* [4-5] 7bit=-2000, 4bit=, 5bit=
* [6]
* [7]
* [8]
* [9]
* [10-11] LE
* [13]
*/
private func parseHistoryBlock(_ block: Data) -> [String: Any]? {
guard block.count >= 16 else { return nil }
@@ -168,10 +174,12 @@ class FelicaReaderSession: NSObject, NFCTagReaderSessionDelegate {
let month = (dateWord >> 5) & 0x0F
let day = dateWord & 0x1F
let entryMinutes = Int(block[6]) * 30
let exitMinutes = Int(block[7]) * 30
let balance = Int(block[8]) | (Int(block[9]) << 8)
let inLineCode = Int(block[6])
let inStationCode = Int(block[7])
let outLineCode = Int(block[8])
let outStationCode = Int(block[9])
let balance = Int(block[10]) | (Int(block[11]) << 8)
let regionCode = Int(block[15])
return [
"terminalType" : terminalType,
@@ -180,16 +188,13 @@ class FelicaReaderSession: NSObject, NFCTagReaderSessionDelegate {
"year" : year,
"month" : month,
"day" : day,
"entryHour" : entryMinutes / 60,
"entryMinute" : entryMinutes % 60,
"exitHour" : exitMinutes / 60,
"exitMinute" : exitMinutes % 60,
"balance" : balance,
"inLineCode" : Int(block[10]),
"inStationCode" : Int(block[11]),
"outLineCode" : Int(block[12]),
"outStationCode" : Int(block[13]),
"companyCode" : Int(block[14])
"inLineCode" : inLineCode,
"inStationCode" : inStationCode,
"outLineCode" : outLineCode,
"outStationCode" : outStationCode,
"companyCode" : Int(block[13]),
"regionCode" : regionCode
]
}
}

View File

@@ -16,14 +16,6 @@ export interface FelicaHistoryEntry {
month: number;
/** 日 (1-31) */
day: number;
/** 入場時刻 - 時 */
entryHour: number;
/** 入場時刻 - 分 */
entryMinute: number;
/** 出場時刻 - 時 */
exitHour: number;
/** 出場時刻 - 分 */
exitMinute: number;
/** 取引後残高(円) */
balance: number;
/** 入場路線コード */
@@ -36,6 +28,8 @@ export interface FelicaHistoryEntry {
outStationCode: number;
/** 会社コード */
companyCode: number;
/** 地域コード (byte[15]): areaCode = regionCode >> 6 */
regionCode: number;
}
/**

View File

@@ -24,6 +24,7 @@
"dayjs": "^1.11.9",
"expo": "^52.0.0",
"expo-alternate-app-icons": "^1.3.0",
"expo-audio": "~0.3.5",
"expo-build-properties": "~0.13.1",
"expo-clipboard": "~7.0.1",
"expo-constants": "~17.0.4",

View File

@@ -10,6 +10,7 @@ import { HeaderConfig } from "@/lib/HeaderConfig";
import useInterval from "@/lib/useInterval";
import { useStationList } from "@/stateBox/useStationList";
import { useUserPosition } from "@/stateBox/useUserPosition";
import { checkDuplicateTrainData } from "@/lib/checkDuplicateTrainData";
import { getStationID } from "@/lib/eachTrainInfoCoreLib/getStationData";
import { trainDataType } from "@/lib/trainPositionTextArray";
@@ -26,6 +27,7 @@ const initialState = {
inject: (i) => {},
fixedPosition: null,
setFixedPosition: (e) => {},
nearestStationID: null,
setInjectData: (e) => {},
getCurrentStationData: ((e) => {}) as (
e: string
@@ -43,10 +45,11 @@ type initialStateType = {
setCurrentTrainLoading: (e: loading) => void;
getCurrentTrain: () => void;
inject: (i: string) => void;
fixedPosition: { type: "station" | "train"; value: string } | null;
fixedPosition: { type: "station" | "train" | "nearestStation"; value: string } | null;
setFixedPosition: (
e: { type: "station" | "train"; value: string } | null
e: { type: "station" | "train" | "nearestStation"; value: string } | null
) => void;
nearestStationID: string | null;
setInjectData: (e: {
type: "station" | "train";
value: string;
@@ -74,9 +77,12 @@ export const CurrentTrainProvider: FC<props> = ({ children }) => {
const { getInjectJavascriptAddress, stationList, originalStationList } =
useStationList();
const { position } = useUserPosition();
const [nearestStationID, setNearestStationID] = useState<string | null>(null);
const [fixedPosition, setFixedPosition] = useState<{
type: "station" | "train";
type: "station" | "train" | "nearestStation";
value: string;
}>({
type: null,
@@ -107,6 +113,31 @@ export const CurrentTrainProvider: FC<props> = ({ children }) => {
fixedPositionSize
);
inject(script);
} else if (fixedPosition.type == "nearestStation") {
if (!position) return;
const userPos = { lat: position.coords.latitude, lng: position.coords.longitude };
const _calcDistance = (from: { lat: number; lng: number }, to: { lat: number; lng: number }) => {
const dlat = Math.abs(from.lat - to.lat);
const dlng = Math.abs(from.lng - to.lng);
return Math.sqrt(dlat * dlat + dlng * dlng);
};
let nearest: StationProps | null = null;
let nearestDist = Infinity;
Object.keys(originalStationList).forEach((lineName) => {
(originalStationList as any)[lineName]?.forEach((station: StationProps) => {
const d = _calcDistance({ lat: station.lat, lng: station.lng }, userPos);
if (d < nearestDist) {
nearestDist = d;
nearest = station;
}
});
});
const stationID = nearest?.StationNumber ?? null;
setNearestStationID(stationID);
if (stationID) {
const script = getInjectJavascriptAddress(stationID, true, fixedPositionSize);
inject(script);
}
} else {
inject(`setReload()`);
}
@@ -299,6 +330,7 @@ export const CurrentTrainProvider: FC<props> = ({ children }) => {
getPosition,
fixedPosition,
setFixedPosition,
nearestStationID,
fixedPositionSize,
setFixedPositionSize,
}}

View File

@@ -11,6 +11,8 @@ import * as Notifications from "expo-notifications";
import * as Device from "expo-device";
import Constants from "expo-constants";
import { logger } from "@/utils/logger";
import { rootNavigationRef } from "@/lib/rootNavigation";
import { SheetManager } from "react-native-actions-sheet";
type initialStateType = {
expoPushToken: string;
};
@@ -107,8 +109,121 @@ export const NotificationProvider: FC<Props> = ({ children }) => {
const [notification, setNotification] = useState<
Notifications.Notification | undefined
>(undefined);
const notificationListener = useRef<Notifications.EventSubscription>();
const responseListener = useRef<Notifications.EventSubscription>();
const notificationListener = useRef<Notifications.EventSubscription | null>(null);
const responseListener = useRef<Notifications.EventSubscription | null>(null);
const handledResponseIdRef = useRef<string | null>(null);
const resolveNotificationRoute = (
response: Notifications.NotificationResponse
): "delay-ex" | "strange-train" | "information" | null => {
const request = response.notification?.request;
const content = request?.content;
const data = (content?.data ?? {}) as Record<string, unknown>;
const triggerRaw = request?.trigger as any;
const channelId =
triggerRaw?.channelId ??
triggerRaw?.remoteMessage?.notification?.channelId ??
"";
const categoryIdentifier = (((content as any)?.categoryIdentifier as string | undefined) ?? "")
.toString()
.trim();
const dataCategory = (
(data.category as string | undefined) ??
(data.type as string | undefined) ??
""
)
.toString()
.trim();
const keySet = new Set(
[channelId, categoryIdentifier, dataCategory]
.filter(Boolean)
.map((v) => v.toLowerCase())
);
if (keySet.has("遅延速報ex") || keySet.has("trainfoex") || keySet.has("tra_info_ex")) {
return "delay-ex";
}
if (keySet.has("怪レい列車bot") || keySet.has("strange_train") || keySet.has("strangetrain")) {
return "strange-train";
}
if (keySet.has("運行情報") || keySet.has("information") || keySet.has("informations")) {
return "information";
}
const normalized = [
content?.title,
content?.subtitle,
content?.body,
channelId,
JSON.stringify(data),
]
.filter(Boolean)
.join(" ")
.toLowerCase();
if (
normalized.includes("遅延") ||
normalized.includes("遅延速報") ||
normalized.includes("trainfoex") ||
normalized.includes("tra_info_ex")
) {
return "delay-ex";
}
if (
normalized.includes("怪レい列車") ||
normalized.includes("怪レい") ||
normalized.includes("strange_train") ||
normalized.includes("strange train")
) {
return "strange-train";
}
if (
normalized.includes("運行情報") ||
normalized.includes("information") ||
normalized.includes("informations")
) {
return "information";
}
return null;
};
const routeFromNotification = (
response: Notifications.NotificationResponse,
retryCount = 0
) => {
const requestId = response.notification?.request?.identifier ?? "";
if (handledResponseIdRef.current === requestId) return;
const action = resolveNotificationRoute(response);
if (!action) return;
if (!rootNavigationRef.isReady()) {
if (retryCount < 8) {
setTimeout(() => routeFromNotification(response, retryCount + 1), 300);
}
return;
}
handledResponseIdRef.current = requestId;
switch (action) {
case "delay-ex":
rootNavigationRef.navigate("topMenu", { screen: "menu" });
setTimeout(() => {
SheetManager.show("JRSTraInfo");
}, 450);
break;
case "strange-train":
rootNavigationRef.navigate("positions", { screen: "Apps" });
break;
case "information":
rootNavigationRef.navigate("information");
break;
default:
break;
}
};
useEffect(() => {
registerForPushNotificationsAsync()
@@ -118,10 +233,18 @@ export const NotificationProvider: FC<Props> = ({ children }) => {
notificationListener.current =
Notifications.addNotificationReceivedListener((notification) => {
setNotification(notification);
}); responseListener.current =
Notifications.addNotificationResponseReceivedListener((response) => {
// 通知レスポンスの処理
});
});
responseListener.current =
Notifications.addNotificationResponseReceivedListener((response) => {
routeFromNotification(response);
});
Notifications.getLastNotificationResponseAsync().then((response) => {
if (response) {
routeFromNotification(response);
}
});
return () => {
notificationListener.current &&