Compare commits
20 Commits
feature/fe
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58b2049b24 | ||
|
|
29bc89f183 | ||
|
|
adfe69b72f | ||
|
|
f387479ff7 | ||
|
|
684aaeb92f | ||
|
|
0a8d5ca2b6 | ||
|
|
48b38a2fa3 | ||
|
|
aeb043cac5 | ||
|
|
2c6ceb73d8 | ||
|
|
f214f45fef | ||
|
|
616846e1cd | ||
|
|
be88a46df1 | ||
|
|
7386ec09fc | ||
|
|
a068dabc75 | ||
|
|
8fda56793a | ||
|
|
83c7dbde63 | ||
|
|
676fbf7b64 | ||
|
|
8bc726628a | ||
|
|
ee22d21862 | ||
|
|
3894694c9b |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -54,4 +54,6 @@ Thumbs.db
|
||||
.cache/
|
||||
|
||||
android/
|
||||
ios/
|
||||
!modules/**/android/
|
||||
ios/
|
||||
!modules/**/ios/
|
||||
53
App.tsx
53
App.tsx
@@ -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,
|
||||
|
||||
30
Apps.tsx
30
Apps.tsx
@@ -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"
|
||||
|
||||
36
app.json
36
app.json
@@ -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
BIN
assets/sound/rikka-test.mp3
Normal file
Binary file not shown.
@@ -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-4592(8:00〜20:00 年中無休)
|
||||
</ListItem.Subtitle>
|
||||
</ListItem.Content>
|
||||
<ListItem.Chevron color="white" />
|
||||
</ListItem>
|
||||
<ScrollView
|
||||
style={{
|
||||
padding: 10,
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
126
components/AndroidWidget/FelicaQuickAccessWidget.tsx
Normal file
126
components/AndroidWidget/FelicaQuickAccessWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
32
components/Apps/FixedPositionBox/FixedNearestStationBox.tsx
Normal file
32
components/Apps/FixedPositionBox/FixedNearestStationBox.tsx
Normal 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} />;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>,
|
||||
])}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -93,6 +93,9 @@ export const STORAGE_KEYS = {
|
||||
|
||||
/** えれサイトデータ */
|
||||
ELESITE_DATA: 'elesiteData',
|
||||
|
||||
/** Felica最終読取スナップショット */
|
||||
FELICA_LAST_SNAPSHOT: 'felicaLastSnapshot',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
16
current.txt
16
current.txt
@@ -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
148
docs/widget-overview.md
Normal 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`) |
|
||||
|
||||
---
|
||||
|
||||
## ディープリンク設定
|
||||
|
||||
### intentFilters(app.json)
|
||||
|
||||
```json
|
||||
[
|
||||
{ "action": "VIEW", "data": [{"scheme": "jrshikoku"}],
|
||||
"category": ["BROWSABLE", "DEFAULT"] },
|
||||
{ "action": "VIEW",
|
||||
"data": [{"scheme": "jrshikoku", "host": "open", "pathPrefix": "/felica"}],
|
||||
"category": ["BROWSABLE", "DEFAULT"] }
|
||||
]
|
||||
```
|
||||
|
||||
### Linking config(Apps.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
5931
lib/felicaStationMap.ts
Normal file
File diff suppressed because it is too large
Load Diff
3
lib/rootNavigation.ts
Normal file
3
lib/rootNavigation.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { createNavigationContainerRef } from "@react-navigation/native";
|
||||
|
||||
export const rootNavigationRef = createNavigationContainerRef<any>();
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user