Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
deb24caaa2 | ||
|
|
5515f42415 | ||
|
|
fbfb83fa34 | ||
|
|
8eb49f57d6 | ||
|
|
c5d4dc3b65 | ||
|
|
7f2480bc01 | ||
|
|
b8372e5087 | ||
|
|
75c07f013d | ||
|
|
46bfea4e13 | ||
|
|
5a2dc8c6a8 | ||
|
|
06650d014a | ||
|
|
45feeece58 | ||
|
|
385c2d8b86 | ||
|
|
41cc70086a | ||
|
|
98c71127aa | ||
|
|
d049d3b07d | ||
|
|
8484f15092 | ||
|
|
1afa5e4377 | ||
|
|
9f6d86b8b6 | ||
|
|
72e7d63bd7 | ||
|
|
b243439e78 | ||
|
|
65f3b2a877 | ||
|
|
925d162f26 | ||
|
|
731fe504c6 | ||
|
|
942ec395f1 | ||
|
|
306cf6882e | ||
|
|
468bb4633a |
+2
-1
@@ -56,4 +56,5 @@ Thumbs.db
|
||||
android/
|
||||
!modules/**/android/
|
||||
ios/
|
||||
!modules/**/ios/build-*.apk
|
||||
!modules/**/ios/
|
||||
*.ipa
|
||||
|
||||
@@ -22,6 +22,7 @@ import { NotificationProvider } from "./stateBox/useNotifications";
|
||||
import { UserPositionProvider } from "./stateBox/useUserPosition";
|
||||
import { rootNavigationRef } from "./lib/rootNavigation";
|
||||
import { AppThemeProvider } from "./lib/theme";
|
||||
import StatusbarDetect from "./StatusbarDetect";
|
||||
|
||||
LogBox.ignoreLogs([
|
||||
"ViewPropTypes will be removed",
|
||||
@@ -106,6 +107,7 @@ export default function App() {
|
||||
<AppThemeProvider>
|
||||
<DeviceOrientationChangeProvider>
|
||||
<SafeAreaProvider>
|
||||
<StatusbarDetect />
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<ProviderTree>
|
||||
<AppContainer />
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import React from "react";
|
||||
import { NavigationContainer } from "@react-navigation/native";
|
||||
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
||||
import { Platform, ActivityIndicator, View } from "react-native";
|
||||
import { Platform, ActivityIndicator, View, useColorScheme } from "react-native";
|
||||
import { useNavigationState } from "@react-navigation/native";
|
||||
import { useFonts } from "expo-font";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import TNDView from "./ndView";
|
||||
import { initIcon } from "./lib/initIcon";
|
||||
import { Top } from "./Top";
|
||||
import { MenuPage } from "./MenuPage";
|
||||
import { useAreaInfo } from "./stateBox/useAreaInfo";
|
||||
import { useTrainMenu } from "./stateBox/useTrainMenu";
|
||||
import lineColorList from "./assets/originData/lineColorList";
|
||||
import { stationIDPair } from "./lib/getStationList";
|
||||
import "./components/ActionSheetComponents/sheets";
|
||||
import { rootNavigationRef } from "./lib/rootNavigation";
|
||||
|
||||
@@ -26,9 +31,26 @@ type TabProps = {
|
||||
isInfo?: boolean;
|
||||
};
|
||||
|
||||
const Tab = createBottomTabNavigator<RootTabParamList>();
|
||||
|
||||
export function AppContainer() {
|
||||
const Tab = createBottomTabNavigator<RootTabParamList>();
|
||||
const { areaInfo, areaIconBadgeText, isInfo } = useAreaInfo();
|
||||
const { selectedLine } = useTrainMenu();
|
||||
|
||||
const lineColor = selectedLine && stationIDPair[selectedLine]
|
||||
? lineColorList[stationIDPair[selectedLine]]
|
||||
: null;
|
||||
|
||||
const darkenHex = (hex: string, factor: number) => {
|
||||
const h = hex.replace("#", "");
|
||||
const r = Math.round(parseInt(h.slice(0, 2), 16) * factor);
|
||||
const g = Math.round(parseInt(h.slice(2, 4), 16) * factor);
|
||||
const b = Math.round(parseInt(h.slice(4, 6), 16) * factor);
|
||||
return `#${[r, g, b].map((v) => Math.min(255, v).toString(16).padStart(2, "0")).join("")}`;
|
||||
};
|
||||
const colorScheme = useColorScheme();
|
||||
const isDark = colorScheme === "dark";
|
||||
const lineColorDark = lineColor ? darkenHex(lineColor, 0.78) : null;
|
||||
const linking = {
|
||||
prefixes: ["jrshikoku://"],
|
||||
config: {
|
||||
@@ -89,10 +111,29 @@ export function AppContainer() {
|
||||
{/* @ts-expect-error - Tab.Navigator type definition issue */}
|
||||
<Tab.Navigator
|
||||
initialRouteName="topMenu"
|
||||
screenOptions={{
|
||||
lazy: false,
|
||||
tabBarHideOnKeyboard: Platform.OS === "android",
|
||||
animation: "shift",
|
||||
screenOptions={({ route }) => {
|
||||
const showGradient = route.name === "positions" && !!lineColor && !!lineColorDark;
|
||||
const defaultBg = isDark ? "#1c1c1e" : "white";
|
||||
const defaultActive = isDark ? "#ffffff" : "#007AFF";
|
||||
const defaultInactive = isDark ? "#8e8e93" : "#8e8e93";
|
||||
return {
|
||||
lazy: false,
|
||||
tabBarHideOnKeyboard: Platform.OS === "android",
|
||||
animation: "shift",
|
||||
tabBarActiveTintColor: showGradient ? "white" : defaultActive,
|
||||
tabBarInactiveTintColor: showGradient ? "rgba(255,255,255,0.75)" : defaultInactive,
|
||||
tabBarStyle: showGradient
|
||||
? { backgroundColor: "transparent" }
|
||||
: { backgroundColor: defaultBg },
|
||||
tabBarBackground: showGradient ? () => (
|
||||
<LinearGradient
|
||||
colors={[lineColor!, lineColorDark!]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
) : undefined,
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Tab.Screen
|
||||
|
||||
+10
-4
@@ -56,6 +56,8 @@ export function MenuPage() {
|
||||
const scrollRef = useRef(null);
|
||||
const [mapMode, setMapMode] = useState(false);
|
||||
const [mapHeight, setMapHeight] = useState(0);
|
||||
const mapHeightRef = useRef(0);
|
||||
const favoriteStationRef = useRef(favoriteStation);
|
||||
useEffect(() => {
|
||||
const MapHeight =
|
||||
height -
|
||||
@@ -64,7 +66,11 @@ export function MenuPage() {
|
||||
100 -
|
||||
((((width / 100) * 80) / 20) * 9 + 10 + 30);
|
||||
setMapHeight(MapHeight);
|
||||
mapHeightRef.current = MapHeight;
|
||||
}, [height, tabBarHeight, width]);
|
||||
useEffect(() => {
|
||||
favoriteStationRef.current = favoriteStation;
|
||||
}, [favoriteStation]);
|
||||
const [MapFullHeight, setMapFullHeight] = useState(0);
|
||||
useEffect(() => {
|
||||
const MapFullHeight =
|
||||
@@ -75,15 +81,15 @@ export function MenuPage() {
|
||||
}, [height, tabBarHeight, width]);
|
||||
useEffect(() => {
|
||||
const unsubscribe = addListener("tabPress", (e) => {
|
||||
scrollRef.current.scrollTo({
|
||||
y: mapHeight - 80,
|
||||
scrollRef.current?.scrollTo({
|
||||
y: mapHeightRef.current - 80,
|
||||
animated: true,
|
||||
});
|
||||
setMapMode(false);
|
||||
AS.getItem(STORAGE_KEYS.FAVORITE_STATION)
|
||||
.then((d) => {
|
||||
const returnData = JSON.parse(d);
|
||||
if (favoriteStation.toString() != d) {
|
||||
if (favoriteStationRef.current.toString() != d) {
|
||||
setFavoriteStation(returnData);
|
||||
}
|
||||
})
|
||||
@@ -95,7 +101,7 @@ export function MenuPage() {
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [navigation, mapHeight, favoriteStation, setFavoriteStation]);
|
||||
}, [navigation]);
|
||||
return (
|
||||
<Stack.Navigator id={null}>
|
||||
<Stack.Screen
|
||||
|
||||
+4
-6
@@ -1,12 +1,10 @@
|
||||
import React, { FC } from "react";
|
||||
import { Platform, StatusBar, View } from "react-native";
|
||||
import { Platform, StatusBar, useColorScheme } from "react-native";
|
||||
|
||||
const StatusbarDetect: FC = () => {
|
||||
if (Platform.OS == "ios") {
|
||||
return <StatusBar barStyle="dark-content" />;
|
||||
} else if (Platform.OS == "android") {
|
||||
return <View />;
|
||||
}
|
||||
const isDark = useColorScheme() === "dark";
|
||||
const barStyle = isDark ? "light-content" : "dark-content";
|
||||
return <StatusBar barStyle={barStyle} translucent backgroundColor="transparent" />;
|
||||
};
|
||||
|
||||
export default StatusbarDetect;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from "react";
|
||||
import React, { useCallback, useEffect, useRef } from "react";
|
||||
import { createStackNavigator } from "@react-navigation/stack";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import Apps from "./components/Apps";
|
||||
@@ -22,6 +22,10 @@ export const Top = () => {
|
||||
|
||||
//地図用
|
||||
const { mapSwitch } = useTrainMenu();
|
||||
const mapSwitchRef = useRef(mapSwitch);
|
||||
useEffect(() => {
|
||||
mapSwitchRef.current = mapSwitch;
|
||||
}, [mapSwitch]);
|
||||
|
||||
const goToFavoriteList = () =>
|
||||
navigate("positions", { screen: "favoriteList" });
|
||||
@@ -31,26 +35,26 @@ export const Top = () => {
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
const goToTrainMenu = () => {
|
||||
const goToTrainMenu = useCallback(() => {
|
||||
if (Platform.OS === "web") {
|
||||
Linking.openURL("https://train.jr-shikoku.co.jp/");
|
||||
setTimeout(() => navigate("topMenu", { screen: "menu" }), 100);
|
||||
return;
|
||||
}
|
||||
if (!isFocused()) navigate("positions", { screen: "Apps" });
|
||||
else if (mapSwitch == "true")
|
||||
else if (mapSwitchRef.current == "true")
|
||||
navigate("positions", { screen: "trainMenu" });
|
||||
else webview.current?.injectJavaScript(`AccordionClassEvent()`);
|
||||
return;
|
||||
};
|
||||
}, [isFocused, navigate, webview]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = addListener("tabPress", goToTrainMenu);
|
||||
return unsubscribe;
|
||||
}, [addListener, mapSwitch]);
|
||||
}, [addListener, goToTrainMenu]);
|
||||
|
||||
return (
|
||||
<Stack.Navigator id={null} detachInactiveScreens={false}>
|
||||
<Stack.Navigator id={null}>
|
||||
<Stack.Screen
|
||||
name="Apps"
|
||||
options={{
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Platform, ToastAndroid } from "react-native";
|
||||
import * as Updates from "expo-updates";
|
||||
|
||||
export const UpdateAsync = () => {
|
||||
if (__DEV__) return; // dev client では expo-updates は無効
|
||||
Updates.checkForUpdateAsync()
|
||||
.then((update) => {
|
||||
if (!update.isAvailable) return;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"android",
|
||||
"web"
|
||||
],
|
||||
"version": "6.0.4",
|
||||
"version": "7.0.0",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icons/s8600.png",
|
||||
@@ -24,9 +24,10 @@
|
||||
"**/*"
|
||||
],
|
||||
"ios": {
|
||||
"buildNumber": "53",
|
||||
"supportsTablet": false,
|
||||
"buildNumber": "58",
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "jrshikokuinfo.xprocess.hrkn",
|
||||
"appleTeamId": "54CRDT797G",
|
||||
"config": {
|
||||
"googleMapsApiKey": "AIzaSyAVGDTjBkR_0wkQiNkoo5WDLhqXCjrjk8Y"
|
||||
},
|
||||
@@ -36,17 +37,22 @@
|
||||
"0003",
|
||||
"FE00"
|
||||
],
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
"ITSAppUsesNonExemptEncryption": false,
|
||||
"NSSupportsLiveActivities": true,
|
||||
"NSSupportsLiveActivitiesFrequentUpdates": true
|
||||
},
|
||||
"entitlements": {
|
||||
"com.apple.developer.nfc.readersession.formats": [
|
||||
"TAG"
|
||||
],
|
||||
"com.apple.security.application-groups": [
|
||||
"group.jrshikokuinfo.xprocess.hrkn"
|
||||
]
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"package": "jrshikokuinfo.xprocess.hrkn",
|
||||
"versionCode": 29,
|
||||
"versionCode": 30,
|
||||
"intentFilters": [
|
||||
{
|
||||
"action": "VIEW",
|
||||
@@ -78,6 +84,7 @@
|
||||
"permissions": [
|
||||
"ACCESS_FINE_LOCATION",
|
||||
"NFC",
|
||||
"POST_NOTIFICATIONS",
|
||||
"android.permission.ACCESS_COARSE_LOCATION",
|
||||
"android.permission.ACCESS_FINE_LOCATION"
|
||||
],
|
||||
@@ -98,6 +105,7 @@
|
||||
},
|
||||
"plugins": [
|
||||
"./plugins/with-android-local-properties",
|
||||
"@bacons/apple-targets",
|
||||
[
|
||||
"expo-font",
|
||||
{
|
||||
@@ -165,6 +173,16 @@
|
||||
"previewImage": "./assets/icon.png",
|
||||
"updatePeriodMillis": 1800000,
|
||||
"resizeMode": "horizontal|vertical"
|
||||
},
|
||||
{
|
||||
"name": "JR_shikoku_felica_balance",
|
||||
"label": "ICカード残高",
|
||||
"minWidth": "70dp",
|
||||
"minHeight": "50dp",
|
||||
"description": "Felica対応ICカードの残高をホーム画面に表示するウィジェットです。タップでスキャン画面を開きます。",
|
||||
"previewImage": "./assets/icon.png",
|
||||
"updatePeriodMillis": 1800000,
|
||||
"resizeMode": "horizontal|vertical"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -499,6 +517,9 @@
|
||||
{
|
||||
"android": {
|
||||
"kotlinVersion": "2.1.20"
|
||||
},
|
||||
"ios": {
|
||||
"deploymentTarget": "16.2"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -506,7 +527,14 @@
|
||||
"expo-video",
|
||||
"expo-web-browser",
|
||||
"expo-asset",
|
||||
"expo-sharing"
|
||||
"expo-sharing",
|
||||
[
|
||||
"react-native-maps",
|
||||
{
|
||||
"iosGoogleMapsApiKey": "AIzaSyAVGDTjBkR_0wkQiNkoo5WDLhqXCjrjk8Y",
|
||||
"androidGoogleMapsApiKey": "AIzaSyAmFb-Yj033bXZWlSzNrfq_0jc1PgRrWcE"
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export const EachStopList: FC<props> = ({
|
||||
array,
|
||||
isNotService = false,
|
||||
}) => {
|
||||
const { colors: themeColors } = useThemeColors();
|
||||
const { colors: themeColors, isDark } = useThemeColors();
|
||||
const [station, se, time, platformNum] = i.split(",") as [
|
||||
string,
|
||||
seTypes,
|
||||
@@ -137,7 +137,8 @@ export const EachStopList: FC<props> = ({
|
||||
isCommunity,
|
||||
isCanceled,
|
||||
isDelayed,
|
||||
isNotService
|
||||
isNotService,
|
||||
isDark
|
||||
);
|
||||
// 打ち消し線用の通常色(遅延していない時の色)
|
||||
const normalColors = getStopListColors(
|
||||
@@ -145,7 +146,8 @@ export const EachStopList: FC<props> = ({
|
||||
isCommunity,
|
||||
isCanceled,
|
||||
false,
|
||||
isNotService
|
||||
isNotService,
|
||||
isDark
|
||||
);
|
||||
|
||||
// beforeSameStationData用の色設定
|
||||
@@ -163,14 +165,16 @@ export const EachStopList: FC<props> = ({
|
||||
beforeIsCommunity,
|
||||
isCanceled,
|
||||
isDelayed,
|
||||
isNotService
|
||||
isNotService,
|
||||
isDark
|
||||
);
|
||||
const beforeNormalColors = getStopListColors(
|
||||
beforeIsThrough,
|
||||
beforeIsCommunity,
|
||||
isCanceled,
|
||||
false,
|
||||
isNotService
|
||||
isNotService,
|
||||
isDark
|
||||
);
|
||||
beforeTimeTextColor = beforeColors.timeText;
|
||||
beforeNormalTimeTextColor = beforeNormalColors.timeText;
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import React, { FC, useCallback, useEffect } from "react";
|
||||
import { Platform, TouchableOpacity, Text, StyleSheet } from "react-native";
|
||||
import { useTrainFollowActivity } from "@/lib/useTrainFollowActivity";
|
||||
import { useAllTrainDiagram } from "@/stateBox/useAllTrainDiagram";
|
||||
import { trainDataType } from "@/lib/trainPositionTextArray";
|
||||
|
||||
type Props = {
|
||||
currentTrainData: trainDataType;
|
||||
currentPosition: string[] | undefined;
|
||||
};
|
||||
|
||||
export const LiveActivityButton: FC<Props> = ({
|
||||
currentTrainData,
|
||||
currentPosition,
|
||||
}) => {
|
||||
const liveActivity = useTrainFollowActivity();
|
||||
const { allTrainDiagram } = useAllTrainDiagram();
|
||||
|
||||
const onLine = !!currentPosition?.toString().length;
|
||||
|
||||
const handleLiveActivity = useCallback(async () => {
|
||||
if (liveActivity.status === "active") {
|
||||
await liveActivity.end();
|
||||
return;
|
||||
}
|
||||
if (!onLine) return;
|
||||
|
||||
const diagramStr: string =
|
||||
(allTrainDiagram as Record<string, string>)[currentTrainData?.num] ?? "";
|
||||
const stations = diagramStr
|
||||
.split("#")
|
||||
.filter(Boolean)
|
||||
.map((s) => s.split(",")[0]);
|
||||
const destination =
|
||||
stations.length > 0 ? stations[stations.length - 1] : "";
|
||||
|
||||
const curSt = currentPosition?.[0] ?? currentTrainData?.Pos ?? "";
|
||||
const curIdx = stations.indexOf(curSt);
|
||||
const nextSt =
|
||||
curIdx >= 0 && curIdx + 1 < stations.length
|
||||
? stations[curIdx + 1]
|
||||
: currentPosition?.[1] ?? "";
|
||||
|
||||
await liveActivity.start({
|
||||
trainNumber: currentTrainData?.num ?? "",
|
||||
lineName: currentTrainData?.Line ?? "",
|
||||
destination,
|
||||
currentStation: curSt,
|
||||
nextStation: nextSt,
|
||||
delayMinutes:
|
||||
typeof currentTrainData?.delay === "number"
|
||||
? currentTrainData.delay
|
||||
: 0,
|
||||
scheduledArrival: "",
|
||||
});
|
||||
}, [
|
||||
liveActivity,
|
||||
onLine,
|
||||
allTrainDiagram,
|
||||
currentTrainData,
|
||||
currentPosition,
|
||||
]);
|
||||
|
||||
// 列車位置が変わったら Live Activity を自動更新
|
||||
useEffect(() => {
|
||||
if (liveActivity.status !== "active") return;
|
||||
if (!currentTrainData?.Pos) return;
|
||||
|
||||
const diagramStr: string =
|
||||
(allTrainDiagram as Record<string, string>)[currentTrainData?.num] ?? "";
|
||||
const stations = diagramStr
|
||||
.split("#")
|
||||
.filter(Boolean)
|
||||
.map((s) => s.split(",")[0]);
|
||||
|
||||
const curSt = currentPosition?.[0] ?? currentTrainData.Pos;
|
||||
const curIdx = stations.indexOf(curSt);
|
||||
const nextSt =
|
||||
curIdx >= 0 && curIdx + 1 < stations.length
|
||||
? stations[curIdx + 1]
|
||||
: currentPosition?.[1] ?? "";
|
||||
|
||||
liveActivity.update({
|
||||
currentStation: curSt,
|
||||
nextStation: nextSt,
|
||||
delayMinutes:
|
||||
typeof currentTrainData.delay === "number"
|
||||
? currentTrainData.delay
|
||||
: 0,
|
||||
scheduledArrival: "",
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
currentTrainData?.Pos,
|
||||
currentTrainData?.delay,
|
||||
currentPosition?.toString(),
|
||||
]);
|
||||
|
||||
if (Platform.OS !== "ios" || !liveActivity.available || !onLine) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={handleLiveActivity}
|
||||
disabled={liveActivity.status === "loading"}
|
||||
style={[
|
||||
styles.liveActivityButton,
|
||||
liveActivity.status === "active" && styles.liveActivityButtonActive,
|
||||
]}
|
||||
>
|
||||
<Text style={styles.liveActivityButtonText}>
|
||||
{liveActivity.status === "active"
|
||||
? "🔴 列車追従 Live Activity 終了"
|
||||
: liveActivity.status === "loading"
|
||||
? "⏳ 開始中..."
|
||||
: "🟢 列車追従 Live Activity 開始"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
liveActivityButton: {
|
||||
marginHorizontal: 10,
|
||||
marginVertical: 6,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 10,
|
||||
backgroundColor: "rgba(0, 153, 204, 0.12)",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(0, 153, 204, 0.5)",
|
||||
alignItems: "center",
|
||||
},
|
||||
liveActivityButtonActive: {
|
||||
backgroundColor: "rgba(220, 50, 50, 0.1)",
|
||||
borderColor: "rgba(220, 50, 50, 0.5)",
|
||||
},
|
||||
liveActivityButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
color: "#0099cc",
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, FC } from "react";
|
||||
import { View, TouchableOpacity, useWindowDimensions } from "react-native";
|
||||
import { View, TouchableOpacity, useWindowDimensions, Text } from "react-native";
|
||||
import { StateBox } from "./StateBox";
|
||||
import { PositionBox } from "./PositionBox";
|
||||
import { useDeviceOrientationChange } from "../../../stateBox/useDeviceOrientationChange";
|
||||
@@ -129,6 +129,8 @@ export const TrainDataView:FC<props> = ({
|
||||
setDialog(true);
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<StationPosPushDialog
|
||||
|
||||
@@ -18,13 +18,15 @@ type ColorScheme = {
|
||||
* @param isCanceled 運休かどうか
|
||||
* @param isDelayed 遅延しているかどうか
|
||||
* @param isNotService 回送列車かどうか
|
||||
* @param isDark ダークモードかどうか
|
||||
*/
|
||||
export const getStopListColors = (
|
||||
isThrough: boolean,
|
||||
isCommunity: boolean,
|
||||
isCanceled: boolean,
|
||||
isDelayed: boolean,
|
||||
isNotService: boolean = false
|
||||
isNotService: boolean = false,
|
||||
isDark: boolean = false
|
||||
): ColorScheme => {
|
||||
// 最優先: 回送列車の場合
|
||||
if (isNotService) {
|
||||
@@ -32,45 +34,47 @@ export const getStopListColors = (
|
||||
// 回送 + 運休
|
||||
if (isThrough) {
|
||||
return {
|
||||
background: '#1a1a1a', // 非常に濃いグレー
|
||||
text: isCommunity ? '#8090c0' : '#909090', // 暗めの青灰色 or 暗めのグレー
|
||||
timeText: isDelayed
|
||||
? (isCommunity ? '#aa7799' : '#aa7777') // 遅延時: 暗めのピンク系の赤
|
||||
: (isCommunity ? '#8090c0' : '#909090'),
|
||||
seText: isCommunity ? '#8090c0' : '#909090',
|
||||
background: isDark ? '#111111' : '#1a1a1a',
|
||||
text: isCommunity ? '#8090c0' : (isDark ? '#707070' : '#909090'),
|
||||
timeText: isDelayed
|
||||
? (isCommunity ? '#aa7799' : '#aa7777')
|
||||
: (isCommunity ? '#8090c0' : (isDark ? '#707070' : '#909090')),
|
||||
seText: isCommunity ? '#8090c0' : (isDark ? '#707070' : '#909090'),
|
||||
opacity: '0.5',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
background: '#3a3a3a', // 濃いグレー
|
||||
text: isCommunity ? '#8090c0' : '#b0b0b0', // 暗めの青灰色 or 明るめのグレー
|
||||
background: isDark ? '#2a2a2a' : '#3a3a3a',
|
||||
text: isCommunity ? '#8090c0' : (isDark ? '#c0c0c0' : '#b0b0b0'),
|
||||
timeText: isDelayed
|
||||
? (isCommunity ? '#bb8899' : '#bb8888') // 遅延時: 暗めのピンク系の赤
|
||||
: (isCommunity ? '#8090c0' : '#b0b0b0'),
|
||||
seText: isCommunity ? '#8090c0' : '#b0b0b0',
|
||||
? (isCommunity ? '#bb8899' : '#bb8888')
|
||||
: (isCommunity ? '#8090c0' : (isDark ? '#c0c0c0' : '#b0b0b0')),
|
||||
seText: isCommunity ? '#8090c0' : (isDark ? '#c0c0c0' : '#b0b0b0'),
|
||||
opacity: '0.8',
|
||||
};
|
||||
}
|
||||
} else if (isThrough) {
|
||||
// 回送 + 通過
|
||||
return {
|
||||
background: '#e8e8e8', // 薄いグレー
|
||||
text: isCommunity ? '#6677cc' : '#777777', // 暗めの青 or グレー
|
||||
timeText: isDelayed
|
||||
? (isCommunity ? '#cc5577' : '#dd5555') // 遅延時: 暗めの赤
|
||||
: (isCommunity ? '#6677cc' : '#777777'),
|
||||
seText: isCommunity ? '#6677cc' : '#777777',
|
||||
background: isDark ? '#2a2a2a' : '#e8e8e8',
|
||||
text: isCommunity ? (isDark ? '#8899ee' : '#6677cc') : (isDark ? '#aaaaaa' : '#777777'),
|
||||
timeText: isDelayed
|
||||
? (isCommunity ? '#cc5577' : '#dd5555')
|
||||
: (isCommunity ? (isDark ? '#8899ee' : '#6677cc') : (isDark ? '#aaaaaa' : '#777777')),
|
||||
seText: isCommunity ? (isDark ? '#8899ee' : '#6677cc') : (isDark ? '#aaaaaa' : '#777777'),
|
||||
opacity: '0.6',
|
||||
};
|
||||
} else {
|
||||
// 回送 + 通常停車
|
||||
return {
|
||||
background: isDelayed ? '#f5f0f0' : '#f5f5f5', // 遅延時は少し赤みのあるグレー
|
||||
text: isCommunity ? '#4455aa' : '#555555', // 暗めの青 or ダークグレー
|
||||
timeText: isDelayed
|
||||
? (isCommunity ? '#bb3355' : '#cc0000') // 遅延時: 暗めの赤
|
||||
: (isCommunity ? '#4455aa' : '#555555'),
|
||||
seText: isCommunity ? '#4455aa' : '#555555',
|
||||
background: isDark
|
||||
? (isDelayed ? '#2a1a1a' : '#222222')
|
||||
: (isDelayed ? '#f5f0f0' : '#f5f5f5'),
|
||||
text: isCommunity ? (isDark ? '#7788cc' : '#4455aa') : (isDark ? '#aaaaaa' : '#555555'),
|
||||
timeText: isDelayed
|
||||
? (isCommunity ? '#bb3355' : '#cc0000')
|
||||
: (isCommunity ? (isDark ? '#7788cc' : '#4455aa') : (isDark ? '#aaaaaa' : '#555555')),
|
||||
seText: isCommunity ? (isDark ? '#7788cc' : '#4455aa') : (isDark ? '#aaaaaa' : '#555555'),
|
||||
opacity: '0.85',
|
||||
};
|
||||
}
|
||||
@@ -81,10 +85,10 @@ export const getStopListColors = (
|
||||
if (isThrough) {
|
||||
// 通過系 + 運休
|
||||
return {
|
||||
background: '#2a2a2a', // 濃いグレー
|
||||
text: isCommunity ? '#a8b5ff' : '#c0c0c0', // 薄い青 or 薄いグレー
|
||||
timeText: isDelayed
|
||||
? (isCommunity ? '#dd99bb' : '#dd9999') // 遅延時: 薄いピンク系の赤
|
||||
background: isDark ? '#1e1e2e' : '#2a2a2a',
|
||||
text: isCommunity ? '#a8b5ff' : '#c0c0c0',
|
||||
timeText: isDelayed
|
||||
? (isCommunity ? '#dd99bb' : '#dd9999')
|
||||
: (isCommunity ? '#a8b5ff' : '#c0c0c0'),
|
||||
seText: isCommunity ? '#a8b5ff' : '#c0c0c0',
|
||||
opacity: '0.6',
|
||||
@@ -92,10 +96,10 @@ export const getStopListColors = (
|
||||
} else {
|
||||
// 通常停車 + 運休
|
||||
return {
|
||||
background: '#5a5a5a', // 中程度のグレー
|
||||
text: isCommunity ? '#a8b5ff' : '#ffffff', // 薄い青 or 白
|
||||
background: isDark ? '#3a3a4a' : '#5a5a5a',
|
||||
text: isCommunity ? '#a8b5ff' : '#ffffff',
|
||||
timeText: isDelayed
|
||||
? (isCommunity ? '#ffaacc' : '#ffaaaa') // 遅延時: 明るいピンク系の赤
|
||||
? (isCommunity ? '#ffaacc' : '#ffaaaa')
|
||||
: (isCommunity ? '#a8b5ff' : '#ffffff'),
|
||||
seText: isCommunity ? '#a8b5ff' : '#ffffff',
|
||||
opacity: '0.9',
|
||||
@@ -106,12 +110,14 @@ export const getStopListColors = (
|
||||
// 次: 通過系の場合
|
||||
if (isThrough) {
|
||||
return {
|
||||
background: isCommunity ? '#f0f4ff' : '#fafafa', // 薄い青背景 or 薄いグレー
|
||||
text: isCommunity ? '#5577ff' : '#888888', // 中程度の青 or グレー
|
||||
timeText: isDelayed
|
||||
? (isCommunity ? '#dd5588' : '#ff6666') // 遅延時: コミュニティは紫がかった赤、通常は明るい赤
|
||||
: (isCommunity ? '#5577ff' : '#888888'),
|
||||
seText: isCommunity ? '#5577ff' : '#888888',
|
||||
background: isDark
|
||||
? (isCommunity ? '#1a1e2e' : '#1e1e1e')
|
||||
: (isCommunity ? '#f0f4ff' : '#fafafa'),
|
||||
text: isCommunity ? (isDark ? '#7799ff' : '#5577ff') : (isDark ? '#666666' : '#888888'),
|
||||
timeText: isDelayed
|
||||
? (isCommunity ? '#dd5588' : '#ff6666')
|
||||
: (isCommunity ? (isDark ? '#7799ff' : '#5577ff') : (isDark ? '#666666' : '#888888')),
|
||||
seText: isCommunity ? (isDark ? '#7799ff' : '#5577ff') : (isDark ? '#666666' : '#888888'),
|
||||
opacity: '0.5',
|
||||
};
|
||||
}
|
||||
@@ -120,19 +126,27 @@ export const getStopListColors = (
|
||||
if (isCommunity) {
|
||||
// コミュニティ投稿
|
||||
return {
|
||||
background: isDelayed ? '#fff5f5' : '#ffffff', // 遅延時は薄い赤背景
|
||||
text: '#3355dd', // 明確な青
|
||||
timeText: isDelayed ? '#cc2266' : '#3355dd', // 遅延時: 紫がかった赤
|
||||
seText: '#3355dd',
|
||||
background: isDark
|
||||
? (isDelayed ? '#2a1a2a' : '#1e1e2e')
|
||||
: (isDelayed ? '#fff5f5' : '#ffffff'),
|
||||
text: isDark ? '#8899ff' : '#3355dd',
|
||||
timeText: isDelayed
|
||||
? (isDark ? '#ee4488' : '#cc2266')
|
||||
: (isDark ? '#8899ff' : '#3355dd'),
|
||||
seText: isDark ? '#8899ff' : '#3355dd',
|
||||
opacity: '0.95',
|
||||
};
|
||||
} else {
|
||||
// 公式データ
|
||||
return {
|
||||
background: isDelayed ? '#fff5f5' : '#ffffff', // 遅延時は薄い赤背景
|
||||
text: '#000000', // 黒
|
||||
timeText: isDelayed ? '#dd0000' : '#000000', // 遅延時: 標準的な赤
|
||||
seText: '#000000',
|
||||
background: isDark
|
||||
? (isDelayed ? '#2a1a1a' : '#1e1e2e')
|
||||
: (isDelayed ? '#fff5f5' : '#ffffff'),
|
||||
text: isDark ? '#e0e0e0' : '#000000',
|
||||
timeText: isDelayed
|
||||
? (isDark ? '#ff5555' : '#dd0000')
|
||||
: (isDark ? '#e0e0e0' : '#000000'),
|
||||
seText: isDark ? '#e0e0e0' : '#000000',
|
||||
opacity: '0.95',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
BackHandler,
|
||||
Linking,
|
||||
} from "react-native";
|
||||
import { SheetManager, useScrollHandlers } from "react-native-actions-sheet";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
import { getTrainType } from "../../lib/getTrainType";
|
||||
import { customTrainDataDetector } from "../custom-train-data";
|
||||
import { useDeviceOrientationChange } from "../../stateBox/useDeviceOrientationChange";
|
||||
@@ -33,6 +33,7 @@ import { useStopStationIDs } from "./EachTrainInfoCore/hooks/useStopStationIDs";
|
||||
import { useTrainPosition } from "./EachTrainInfoCore/hooks/useTrainPosition";
|
||||
import { useAutoScroll } from "./EachTrainInfoCore/hooks/useAutoScroll";
|
||||
import { useExtendedStations } from "./EachTrainInfoCore/hooks/useExtendedStations";
|
||||
import { LiveActivityButton } from "./EachTrainInfo/LiveActivityButton";
|
||||
|
||||
export const EachTrainInfoCore = ({
|
||||
actionSheetRef,
|
||||
@@ -48,10 +49,7 @@ export const EachTrainInfoCore = ({
|
||||
const { height } = useWindowDimensions();
|
||||
const { isLandscape } = useDeviceOrientationChange();
|
||||
|
||||
const scrollHandlers = actionSheetRef
|
||||
? //@ts-ignore
|
||||
useScrollHandlers("scrollview-1", actionSheetRef)
|
||||
: null;
|
||||
const scrollRef = useRef<any>(null);
|
||||
// Custom hooks for data management
|
||||
const { trainData, setTrainData, trueTrainID } = useTrainDiagramData(
|
||||
data.trainNum
|
||||
@@ -80,7 +78,7 @@ export const EachTrainInfoCore = ({
|
||||
useAutoScroll(
|
||||
points,
|
||||
trainDataWithThrough,
|
||||
scrollHandlers,
|
||||
scrollRef,
|
||||
isJumped,
|
||||
setIsJumped,
|
||||
setShowThrew
|
||||
@@ -166,13 +164,13 @@ export const EachTrainInfoCore = ({
|
||||
navigate={navigate}
|
||||
from={from}
|
||||
fontLoaded={true}
|
||||
scrollHandlers={scrollHandlers}
|
||||
scrollRef={scrollRef}
|
||||
/>
|
||||
|
||||
<DynamicHeaderScrollView
|
||||
from={from}
|
||||
styles={styles as any}
|
||||
scrollHandlers={scrollHandlers}
|
||||
scrollRef={scrollRef}
|
||||
containerProps={{
|
||||
style: {
|
||||
maxHeight: isLandscape ? height - 94 : (height / 100) * 70,
|
||||
@@ -208,6 +206,10 @@ export const EachTrainInfoCore = ({
|
||||
/>
|
||||
}
|
||||
>
|
||||
<LiveActivityButton
|
||||
currentTrainData={currentTrainData}
|
||||
currentPosition={currentPosition}
|
||||
/>
|
||||
{customTrainType.data === "notService" && (
|
||||
<Text style={{ backgroundColor: colors.surface, fontWeight: "bold" }}>
|
||||
この列車には乗車できません。
|
||||
|
||||
@@ -27,7 +27,7 @@ type Props = {
|
||||
navigate: NavigateFunction;
|
||||
from: string;
|
||||
fontLoaded: boolean;
|
||||
scrollHandlers: any;
|
||||
scrollRef: any;
|
||||
};
|
||||
|
||||
const textConfig: TextStyle = {
|
||||
@@ -44,7 +44,7 @@ export const HeaderText: FC<Props> = ({
|
||||
tailStation,
|
||||
navigate,
|
||||
from,
|
||||
scrollHandlers,
|
||||
scrollRef,
|
||||
}) => {
|
||||
const { limited, trainNum } = data;
|
||||
|
||||
@@ -197,7 +197,7 @@ export const HeaderText: FC<Props> = ({
|
||||
width: "100%",
|
||||
}}
|
||||
onTouchStart={() =>
|
||||
scrollHandlers.ref.current?.scrollTo({ y: 0, animated: true })
|
||||
scrollRef.current?.current?.scrollTo({ y: 0, animated: true })
|
||||
}
|
||||
>
|
||||
<TrainIconStatus
|
||||
|
||||
@@ -5,13 +5,13 @@ import { LayoutAnimation, ScrollView } from 'react-native';
|
||||
export const useAutoScroll = (
|
||||
points: boolean[] | undefined,
|
||||
trainDataWithThrough: string[],
|
||||
scrollHandlers: any,
|
||||
scrollRef: MutableRefObject<any>,
|
||||
isJumped: boolean,
|
||||
setIsJumped: (value: boolean) => void,
|
||||
setShowThrew: (value: boolean) => void
|
||||
) => {
|
||||
useEffect(() => {
|
||||
if (isJumped || !points?.length || !scrollHandlers) return;
|
||||
if (isJumped || !points?.length || !scrollRef) return;
|
||||
|
||||
const currentPositionIndex = points.findIndex((d) => d === true);
|
||||
if (currentPositionIndex === -1) return;
|
||||
@@ -34,8 +34,8 @@ export const useAutoScroll = (
|
||||
|
||||
const scrollPosition = currentPositionIndex * 44 - 50;
|
||||
setTimeout(() => {
|
||||
scrollHandlers.ref.current?.scrollTo({ y: scrollPosition, animated: true });
|
||||
scrollRef.current?.current?.scrollTo({ y: scrollPosition, animated: true });
|
||||
setIsJumped(true);
|
||||
}, 400);
|
||||
}, [points, trainDataWithThrough, scrollHandlers, isJumped, setIsJumped, setShowThrew]);
|
||||
}, [points, trainDataWithThrough, scrollRef, isJumped, setIsJumped, setShowThrew]);
|
||||
};
|
||||
|
||||
@@ -154,9 +154,10 @@ export const Social = () => {
|
||||
tension={100} // These props are passed to the parent component (here TouchableScale)
|
||||
activeScale={0.95} //
|
||||
Component={TouchableScale}
|
||||
containerStyle={{ backgroundColor: colors.surface }}
|
||||
>
|
||||
<ListItem.Content>
|
||||
<ListItem.Title>{d.name}</ListItem.Title>
|
||||
<ListItem.Title style={{ color: colors.text }}>{d.name}</ListItem.Title>
|
||||
</ListItem.Content>
|
||||
<ListItem.Chevron />
|
||||
</ListItem>
|
||||
|
||||
@@ -218,7 +218,7 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
|
||||
const formationDetail = (
|
||||
<View style={styles.operationDetailBlock}>
|
||||
{hasNonEmptyFormations && (
|
||||
<Text style={styles.unitIdText}>{formationNames}</Text>
|
||||
<Text style={[styles.unitIdText, { color: colors.textAccent }]}>{formationNames}</Text>
|
||||
)}
|
||||
<RefDirectionBanner
|
||||
rows={[{ leftLabel: "宇和島/宿毛/阿波海南" }]}
|
||||
@@ -311,9 +311,10 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
|
||||
gestureEnabled
|
||||
CustomHeaderComponent={<></>}
|
||||
isModal={Platform.OS === "ios"}
|
||||
containerStyle={{ backgroundColor: colors.sheetBackground }}
|
||||
>
|
||||
{/* ヘッダー */}
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.header, { backgroundColor: colors.sheetBackground }]}>
|
||||
<View style={[styles.handleBar, { backgroundColor: colors.border }]} />
|
||||
<View style={styles.headerRow}>
|
||||
<Text style={[styles.headerTitle, { color: colors.textPrimary }]}>運用情報ソース</Text>
|
||||
@@ -322,7 +323,7 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
style={[styles.scroll, { backgroundColor: colors.sheetBackground }]}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
{/* ─── jr-shikoku-data-system (列車情報 + 運用情報) ─── */}
|
||||
@@ -359,7 +360,7 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
|
||||
{unyohubEnabled && (
|
||||
<SourceCard
|
||||
imagePng={HUB_LOGO_PNG}
|
||||
color="#333"
|
||||
color={colors.textSecondary}
|
||||
title="鉄道運用Hub"
|
||||
label="外部コミュニティデータ"
|
||||
sub={
|
||||
@@ -370,7 +371,7 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
|
||||
: "この列車の運用データはありません"
|
||||
}
|
||||
badge={hasNonEmptyFormations ? unyoCount : null}
|
||||
badgeColor="#333"
|
||||
badgeColor={colors.textSecondary}
|
||||
detail={formationDetail}
|
||||
disabled={unyoCount === 0}
|
||||
onPress={() =>
|
||||
@@ -425,21 +426,24 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* DirectionBanner: 進行方向表示 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
const DirectionBanner: FC<{ direction: boolean }> = ({ direction }) => (
|
||||
<View style={styles.directionBanner}>
|
||||
{direction ? (
|
||||
<>
|
||||
<MaterialCommunityIcons name="arrow-left" size={13} color="#0099CC" />
|
||||
<Text style={styles.directionText}>進行方向</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.directionText}>進行方向</Text>
|
||||
<MaterialCommunityIcons name="arrow-right" size={13} color="#0099CC" />
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
const DirectionBanner: FC<{ direction: boolean }> = ({ direction }) => {
|
||||
const { colors, fixed } = useThemeColors();
|
||||
return (
|
||||
<View style={[styles.directionBanner, { backgroundColor: colors.backgroundSecondary }]}>
|
||||
{direction ? (
|
||||
<>
|
||||
<MaterialCommunityIcons name="arrow-left" size={13} color={fixed.primary} />
|
||||
<Text style={[styles.directionText, { color: fixed.primary }]}>進行方向</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={[styles.directionText, { color: fixed.primary }]}>進行方向</Text>
|
||||
<MaterialCommunityIcons name="arrow-right" size={13} color={fixed.primary} />
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* RefDirectionBanner: 基準方向ラベル */
|
||||
@@ -704,7 +708,7 @@ const TrainInfoDetail: FC<{
|
||||
/>
|
||||
)}
|
||||
{!!destDisplay && (
|
||||
<Text style={[styles.routeStation, styles.routeDest]}>
|
||||
<Text style={[styles.routeStation, styles.routeDest, { color: colors.textPrimary }]}>
|
||||
{destDisplay}
|
||||
</Text>
|
||||
)}
|
||||
@@ -750,13 +754,13 @@ const TrainInfoDetail: FC<{
|
||||
</View>
|
||||
)}
|
||||
{!!(start_date || end_date) && (
|
||||
<View style={[styles.metaChip, styles.metaChipOrange]}>
|
||||
<View style={[styles.metaChip, styles.metaChipOrange, { backgroundColor: colors.backgroundTertiary }]}>
|
||||
<MaterialCommunityIcons
|
||||
name="calendar-range"
|
||||
size={11}
|
||||
color="#bf360c"
|
||||
color={colors.textWarning}
|
||||
/>
|
||||
<Text style={[styles.metaChipText, { color: "#bf360c" }]}>
|
||||
<Text style={[styles.metaChipText, { color: colors.textWarning }]}>
|
||||
{start_date ?? ""}〜{end_date ?? ""}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -772,9 +776,9 @@ const TrainInfoDetail: FC<{
|
||||
<MaterialCommunityIcons
|
||||
name="message-text-outline"
|
||||
size={12}
|
||||
color="#888"
|
||||
color={colors.iconSecondary}
|
||||
/>
|
||||
<Text style={styles.noteText}>{uwasa}</Text>
|
||||
<Text style={[styles.noteText, { color: colors.textSecondary }]}>{uwasa}</Text>
|
||||
</View>
|
||||
)}
|
||||
{!!optional_text && (
|
||||
@@ -782,9 +786,9 @@ const TrainInfoDetail: FC<{
|
||||
<MaterialCommunityIcons
|
||||
name="information-outline"
|
||||
size={12}
|
||||
color="#888"
|
||||
color={colors.iconSecondary}
|
||||
/>
|
||||
<Text style={styles.noteText}>{optional_text}</Text>
|
||||
<Text style={[styles.noteText, { color: colors.textSecondary }]}>{optional_text}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
FlatList,
|
||||
KeyboardAvoidingView,
|
||||
TextInput,
|
||||
Platform,
|
||||
Keyboard,
|
||||
@@ -36,6 +35,7 @@ export const AllTrainDiagramView: FC = () => {
|
||||
} = useAllTrainDiagram();
|
||||
const [input, setInput] = useState(""); // 文字入力
|
||||
const [keyBoardVisible, setKeyBoardVisible] = useState(false);
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||
const [useStationName, setUseStationName] = useState(false);
|
||||
const [useRegex, setUseRegex] = useState(false);
|
||||
const regexTextStyle = {
|
||||
@@ -52,11 +52,13 @@ export const AllTrainDiagramView: FC = () => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const showSubscription = Keyboard.addListener("keyboardDidShow", () => {
|
||||
const showSubscription = Keyboard.addListener("keyboardDidShow", (e) => {
|
||||
setKeyBoardVisible(true);
|
||||
setKeyboardHeight(e.endCoordinates.height);
|
||||
});
|
||||
const hideSubscription = Keyboard.addListener("keyboardDidHide", () => {
|
||||
setKeyBoardVisible(false);
|
||||
setKeyboardHeight(0);
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -209,7 +211,7 @@ export const AllTrainDiagramView: FC = () => {
|
||||
);
|
||||
};
|
||||
return (
|
||||
<View style={{ backgroundColor: fixed.primary, height: "100%" }}>
|
||||
<View style={{ flex: 1, backgroundColor: fixed.primary, paddingBottom: Platform.OS === "ios" ? keyboardHeight : 0 }}>
|
||||
<FlatList
|
||||
contentContainerStyle={{ justifyContent: "flex-end", flexGrow: 1 }}
|
||||
style={{ flex: 1 }}
|
||||
@@ -259,11 +261,7 @@ export const AllTrainDiagramView: FC = () => {
|
||||
keyExtractor={(item) => item}
|
||||
//initialNumToRender={100}
|
||||
/>
|
||||
<KeyboardAvoidingView
|
||||
behavior="padding"
|
||||
keyboardVerticalOffset={80}
|
||||
enabled={Platform.OS === "ios"}
|
||||
>
|
||||
<View>
|
||||
<View style={{ height: 35, flexDirection: "row" }}>
|
||||
<Switch
|
||||
value={useRegex}
|
||||
@@ -367,7 +365,7 @@ export const AllTrainDiagramView: FC = () => {
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
<BigButton
|
||||
onPress={goBack}
|
||||
string="閉じる"
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import { WidgetPreview } from "react-native-android-widget";
|
||||
|
||||
import { HelloWidget } from "./HelloWidget";
|
||||
|
||||
export function HelloWidgetPreviewScreen() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<WidgetPreview
|
||||
renderWidget={() => <HelloWidget />}
|
||||
width={200}
|
||||
height={200}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
ListWidget,
|
||||
} from "react-native-android-widget";
|
||||
import dayjs from "dayjs";
|
||||
import { ToastAndroid } from "react-native";
|
||||
|
||||
export const getInfoString = async () => {
|
||||
// Fetch data from the server
|
||||
@@ -104,9 +103,3 @@ export function InfoWidget({ time, text }) {
|
||||
</FlexWidget>
|
||||
);
|
||||
}
|
||||
|
||||
const FlexText = ({ flex, text }) => (
|
||||
<FlexWidget style={{ flex }}>
|
||||
<TextWidget style={{ fontSize: 20, color: "#000000" }} text={text} />
|
||||
</FlexWidget>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import React from "react";
|
||||
import { FlexWidget, TextWidget } from "react-native-android-widget";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export function getShortcutData() {
|
||||
return { nowText: dayjs().format("HH:mm") };
|
||||
}
|
||||
|
||||
const shortcuts = [
|
||||
{ label: "列車位置", icon: "🚃", uri: "jrshikoku://open/traininfo" },
|
||||
{ label: "運行情報", icon: "📋", uri: "jrshikoku://open/operation" },
|
||||
{ label: "IC読取", icon: "💳", uri: "jrshikoku://open/felica" },
|
||||
{ label: "設定", icon: "⚙️", uri: "jrshikoku://open/settings" },
|
||||
];
|
||||
|
||||
export function ShortcutWidget({ nowText }: { nowText: string }) {
|
||||
return (
|
||||
<FlexWidget
|
||||
style={{
|
||||
height: "match_parent",
|
||||
width: "match_parent",
|
||||
backgroundColor: "#0B1D2A",
|
||||
borderRadius: 20,
|
||||
padding: 14,
|
||||
}}
|
||||
>
|
||||
<FlexWidget
|
||||
style={{
|
||||
width: "match_parent",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
<TextWidget
|
||||
text="JR四国 ショートカット"
|
||||
style={{ color: "#8EC5FF", fontSize: 14, fontWeight: "bold" }}
|
||||
/>
|
||||
<TextWidget
|
||||
text={nowText}
|
||||
style={{ color: "#D3E6FF", fontSize: 12 }}
|
||||
/>
|
||||
</FlexWidget>
|
||||
|
||||
<FlexWidget
|
||||
style={{
|
||||
width: "match_parent",
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "space-between",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{shortcuts.map((s) => (
|
||||
<FlexWidget
|
||||
key={s.label}
|
||||
style={{
|
||||
width: "48%",
|
||||
backgroundColor: "#132F46",
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
marginBottom: 8,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
clickAction="OPEN_URI"
|
||||
clickActionData={{ uri: s.uri }}
|
||||
>
|
||||
<TextWidget
|
||||
text={s.icon}
|
||||
style={{ fontSize: 28, marginBottom: 4 }}
|
||||
/>
|
||||
<TextWidget
|
||||
text={s.label}
|
||||
style={{ color: "#FFFFFF", fontSize: 14, fontWeight: "bold" }}
|
||||
/>
|
||||
</FlexWidget>
|
||||
))}
|
||||
</FlexWidget>
|
||||
</FlexWidget>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
ListWidget,
|
||||
} from "react-native-android-widget";
|
||||
import dayjs from "dayjs";
|
||||
import { ToastAndroid } from "react-native";
|
||||
|
||||
export const getDelayData = async () => {
|
||||
// Fetch data from the server
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
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";
|
||||
import { ShortcutWidget, getShortcutData } from "./ShortcutWidget";
|
||||
|
||||
export const nameToWidget = {
|
||||
JR_shikoku_train_info: TraInfoEXWidget,
|
||||
Info_Widget: InfoWidget,
|
||||
JR_shikoku_apps_shortcut: FelicaQuickAccessWidget,
|
||||
JR_shikoku_apps_shortcut: ShortcutWidget,
|
||||
JR_shikoku_felica_balance: FelicaQuickAccessWidget,
|
||||
};
|
||||
|
||||
export async function widgetTaskHandler(props) {
|
||||
@@ -22,47 +22,45 @@ export async function widgetTaskHandler(props) {
|
||||
clickAction,
|
||||
clickActionData,
|
||||
} = props;
|
||||
const WidgetName = await AS.getItem(
|
||||
`widgetType/${widgetInfo.widgetId}`
|
||||
).catch((e) => "JR_shikoku_train_info");
|
||||
// ToastAndroid.show(
|
||||
// `Widget Action: ${JSON.stringify(widgetInfo.widgetId)}`,
|
||||
// ToastAndroid.SHORT
|
||||
// );
|
||||
//ToastAndroid.show(`Widget Name: ${WidgetName}`, ToastAndroid.SHORT);
|
||||
|
||||
switch (widgetAction) {
|
||||
case "WIDGET_ADDED":
|
||||
case "WIDGET_UPDATE":
|
||||
case "WIDGET_CLICK":
|
||||
case "WIDGET_RESIZED":
|
||||
if (widgetInfo.widgetName === "JR_shikoku_apps_shortcut") {
|
||||
case "WIDGET_RESIZED": {
|
||||
const name = widgetInfo.widgetName;
|
||||
|
||||
if (name === "JR_shikoku_felica_balance") {
|
||||
const quickData = await getFelicaQuickAccessData();
|
||||
renderWidget(<FelicaQuickAccessWidget {...quickData} />);
|
||||
break;
|
||||
}
|
||||
|
||||
switch (WidgetName) {
|
||||
case "Info_Widget": {
|
||||
const { time, text } = await getInfoString();
|
||||
renderWidget(
|
||||
<InfoWidget time={time} text={text && text.toString()} />
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "JR_shikoku_train_info":
|
||||
default: {
|
||||
const { time, delayString } = await getDelayData();
|
||||
renderWidget(
|
||||
<TraInfoEXWidget time={time} delayString={delayString} />
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (name === "JR_shikoku_apps_shortcut") {
|
||||
const data = getShortcutData();
|
||||
renderWidget(<ShortcutWidget {...data} />);
|
||||
break;
|
||||
}
|
||||
|
||||
if (name === "JR_shikoku_info") {
|
||||
const { time, text } = await getInfoString();
|
||||
renderWidget(
|
||||
<InfoWidget time={time} text={text && text.toString()} />
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// JR_shikoku_train_info and default
|
||||
{
|
||||
const { time, delayString } = await getDelayData();
|
||||
renderWidget(
|
||||
<TraInfoEXWidget time={time} delayString={delayString} />
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "WIDGET_DELETED":
|
||||
AS.removeItem(`widgetType/${widgetInfo.widgetId}`);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
|
||||
+46
-17
@@ -3,11 +3,15 @@ import {
|
||||
View,
|
||||
Platform,
|
||||
useWindowDimensions,
|
||||
StatusBar,
|
||||
useColorScheme,
|
||||
} from "react-native";
|
||||
import Constants from "expo-constants";
|
||||
import * as Updates from "expo-updates";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
|
||||
import { lineList } from "../lib/getStationList";
|
||||
import lineColorList from "../assets/originData/lineColorList";
|
||||
import { lineList, stationIDPair } from "../lib/getStationList";
|
||||
import { useCurrentTrain } from "../stateBox/useCurrentTrain";
|
||||
import { useDeviceOrientationChange } from "../stateBox/useDeviceOrientationChange";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
@@ -20,20 +24,31 @@ import { MapsButton } from "./Apps/MapsButton";
|
||||
import { ReloadButton } from "./Apps/ReloadButton";
|
||||
import { useStationList } from "../stateBox/useStationList";
|
||||
import { FixedPositionBox } from "./Apps/FixedPositionBox";
|
||||
/*
|
||||
import StatusbarDetect from '../StatusbarDetect';
|
||||
var Status = StatusbarDetect(); */
|
||||
|
||||
const top = Platform.OS == "ios" ? Constants.statusBarHeight : 0;
|
||||
|
||||
export default function Apps() {
|
||||
const { webview, fixedPosition, setFixedPosition } = useCurrentTrain();
|
||||
const { height, width } = useWindowDimensions();
|
||||
const { navigate } = useNavigation();
|
||||
const { isLandscape } = useDeviceOrientationChange();
|
||||
const { top } = useSafeAreaInsets();
|
||||
const handleLayout = () => {};
|
||||
const { originalStationList } = useStationList();
|
||||
const { mapSwitch, trainInfo, setTrainInfo } = useTrainMenu();
|
||||
const { mapSwitch, trainInfo, setTrainInfo, selectedLine } = useTrainMenu();
|
||||
const isDark = useColorScheme() === "dark";
|
||||
|
||||
const lineColor = selectedLine && stationIDPair[selectedLine]
|
||||
? lineColorList[stationIDPair[selectedLine]]
|
||||
: null;
|
||||
|
||||
// 路線色の両端を暗くしたグラデーション用カラー
|
||||
const darkenHex = (hex: string, factor: number) => {
|
||||
const h = hex.replace("#", "");
|
||||
const r = Math.round(parseInt(h.slice(0, 2), 16) * factor);
|
||||
const g = Math.round(parseInt(h.slice(2, 4), 16) * factor);
|
||||
const b = Math.round(parseInt(h.slice(4, 6), 16) * factor);
|
||||
return `#${[r, g, b].map((v) => Math.min(255, v).toString(16).padStart(2, "0")).join("")}`;
|
||||
};
|
||||
const lineColorDark = lineColor ? darkenHex(lineColor, 0.55) : null;
|
||||
|
||||
const openStationACFromEachTrainInfo = async (stationName) => {
|
||||
await SheetManager.hide("EachTrainInfo");
|
||||
@@ -68,15 +83,28 @@ export default function Apps() {
|
||||
}
|
||||
};
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
height: "100%",
|
||||
paddingTop: top,
|
||||
flexDirection: isLandscape ? "row" : "column",
|
||||
}}
|
||||
onLayout={handleLayout}
|
||||
>
|
||||
{/* {Status} */}
|
||||
<View style={{ flex: 1 }}>
|
||||
{lineColor && lineColorDark && (
|
||||
<LinearGradient
|
||||
colors={[lineColorDark, lineColor]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
style={{ position: "absolute", top: 0, left: 0, right: 0, height: top }}
|
||||
/>
|
||||
)}
|
||||
{lineColor && (
|
||||
<StatusBar
|
||||
barStyle="light-content"
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingTop: top,
|
||||
flexDirection: isLandscape ? "row" : "column",
|
||||
}}
|
||||
onLayout={handleLayout}
|
||||
>
|
||||
<AppsWebView
|
||||
{...{
|
||||
openStationACFromEachTrainInfo,
|
||||
@@ -99,6 +127,7 @@ export default function Apps() {
|
||||
) : (
|
||||
<NewMenu />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,85 @@
|
||||
import { useCurrentTrain } from "@/stateBox/useCurrentTrain";
|
||||
import { View, Platform } from "react-native";
|
||||
import { useKeepAwake } from "expo-keep-awake";
|
||||
import Constants from "expo-constants";
|
||||
import { AppState, InteractionManager, View } from "react-native";
|
||||
import {
|
||||
activateKeepAwakeAsync,
|
||||
deactivateKeepAwake,
|
||||
} from "expo-keep-awake";
|
||||
import { FixedTrain } from "./FixedPositionBox/FixedTrainBox";
|
||||
import { FixedStation } from "./FixedPositionBox/FixedStationBox";
|
||||
import { FixedNearestStationBox } from "./FixedPositionBox/FixedNearestStationBox";
|
||||
import { useEffect } from "react";
|
||||
import { useTrainMenu } from "@/stateBox/useTrainMenu";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const KEEP_AWAKE_TAG = "fixed-position-box";
|
||||
|
||||
const isActivityUnavailableError = (error: unknown) =>
|
||||
String(error).includes("The current activity is no longer available");
|
||||
|
||||
export const FixedPositionBox = () => {
|
||||
const { mapSwitch } = useTrainMenu();
|
||||
const { fixedPosition, fixedPositionSize, setFixedPositionSize } =
|
||||
useCurrentTrain();
|
||||
const { top } = useSafeAreaInsets();
|
||||
useEffect(() => {
|
||||
setFixedPositionSize(mapSwitch == "true" ? 76 : 80);
|
||||
}, [mapSwitch]);
|
||||
|
||||
useKeepAwake();
|
||||
useEffect(() => {
|
||||
if (__DEV__) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
let timerId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const activate = async () => {
|
||||
if (!mounted || AppState.currentState !== "active") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await activateKeepAwakeAsync(KEEP_AWAKE_TAG);
|
||||
} catch (error) {
|
||||
if (!isActivityUnavailableError(error)) {
|
||||
console.warn("Failed to activate keep awake", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const interactionHandle = InteractionManager.runAfterInteractions(() => {
|
||||
timerId = setTimeout(() => {
|
||||
void activate();
|
||||
}, 250);
|
||||
});
|
||||
|
||||
const subscription = AppState.addEventListener("change", (state) => {
|
||||
if (state === "active") {
|
||||
timerId = setTimeout(() => {
|
||||
void activate();
|
||||
}, 250);
|
||||
return;
|
||||
}
|
||||
|
||||
deactivateKeepAwake(KEEP_AWAKE_TAG).catch(() => {});
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (timerId) {
|
||||
clearTimeout(timerId);
|
||||
}
|
||||
interactionHandle.cancel();
|
||||
subscription.remove();
|
||||
deactivateKeepAwake(KEEP_AWAKE_TAG).catch(() => {});
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: Platform.OS == "ios" ? Constants.statusBarHeight : 0,
|
||||
top,
|
||||
borderRadius: 5,
|
||||
zIndex: 1500,
|
||||
width: "100%",
|
||||
|
||||
@@ -23,6 +23,14 @@ import { FC, useEffect, useRef, useState } from "react";
|
||||
import { Animated, LayoutAnimation, Text, TouchableOpacity, View } from "react-native";
|
||||
import { SheetManager } from "react-native-actions-sheet";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import {
|
||||
startStationLockActivity,
|
||||
updateStationLockActivity,
|
||||
endStationLockActivity,
|
||||
isLiveNotificationActive,
|
||||
isAvailable as isLiveActivityAvailable,
|
||||
StationTrainInfo,
|
||||
} from "expo-live-activity";
|
||||
|
||||
type props = {
|
||||
stationID: string;
|
||||
@@ -37,8 +45,11 @@ export const FixedStation: FC<props> = ({ stationID }) => {
|
||||
setFixedPosition,
|
||||
fixedPositionSize,
|
||||
setFixedPositionSize,
|
||||
liveNotificationActive,
|
||||
setLiveNotificationActive,
|
||||
} = useCurrentTrain();
|
||||
const { getStationDataFromId } = useStationList();
|
||||
const { stationList } = useStationList();
|
||||
const { navigate } = useNavigation();
|
||||
const [station, setStation] = useState<StationProps[]>([]);
|
||||
|
||||
@@ -69,7 +80,7 @@ export const FixedStation: FC<props> = ({ stationID }) => {
|
||||
: "white";
|
||||
////
|
||||
|
||||
const { allTrainDiagram } = useAllTrainDiagram();
|
||||
const { allTrainDiagram, allCustomTrainData } = useAllTrainDiagram();
|
||||
const { areaStationID } = useAreaInfo();
|
||||
const [stationDiagram, setStationDiagram] = useState({}); //当該駅の全時刻表
|
||||
const [isInfoArea, setIsInfoArea] = useState(false);
|
||||
@@ -118,6 +129,98 @@ export const FixedStation: FC<props> = ({ stationID }) => {
|
||||
setSelectedTrain(data);
|
||||
}, [trainTimeAndNumber, currentTrain /*finalSwitch*/]);
|
||||
|
||||
// ── Live Notification ──
|
||||
const [liveNotifyId, setLiveNotifyId] = useState<string | null>(null);
|
||||
const liveNotifyIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
liveNotifyIdRef.current = liveNotifyId;
|
||||
}, [liveNotifyId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (liveNotifyIdRef.current) {
|
||||
endStationLockActivity(liveNotifyIdRef.current).catch(() => {});
|
||||
setLiveNotificationActive(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const buildTrainsInfo = (): StationTrainInfo[] => {
|
||||
return selectedTrain.slice(0, 7).map((d) => {
|
||||
const customData = getCurrentTrainData(
|
||||
d.train,
|
||||
currentTrain,
|
||||
allCustomTrainData
|
||||
);
|
||||
const currentTrainData = checkDuplicateTrainData(
|
||||
currentTrain.filter((a) => a.num === d.train),
|
||||
stationList
|
||||
);
|
||||
const { name } = getTrainType({ type: customData.type, whiteMode: true });
|
||||
const delayStatus = `${getTrainDelayStatus(
|
||||
currentTrainData,
|
||||
station[0]?.Station_JP
|
||||
)}`;
|
||||
return {
|
||||
time: d.time,
|
||||
typeName: name,
|
||||
trainName: customData.train_name || "",
|
||||
destination: d.lastStation,
|
||||
platform: "",
|
||||
delayStatus: delayStatus || "定刻",
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!liveNotifyId || station.length === 0) return;
|
||||
const trains = buildTrainsInfo();
|
||||
updateStationLockActivity(liveNotifyId, {
|
||||
nextTrainTime: trains[0]?.time || "",
|
||||
nextTrainDestination: trains[0]?.destination || "",
|
||||
nextTrainPlatform: trains[0]?.platform || "",
|
||||
followingTrainTime: trains[1]?.time || "",
|
||||
followingTrainDestination: trains[1]?.destination || "",
|
||||
stationName: station[0]?.Station_JP,
|
||||
stationNumber: station[0]?.StationNumber,
|
||||
lineColor,
|
||||
trains,
|
||||
}).catch(() => {});
|
||||
}, [selectedTrain, currentTrain, liveNotifyId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (liveNotifyId && !isLiveNotificationActive()) {
|
||||
setLiveNotifyId(null);
|
||||
setLiveNotificationActive(false);
|
||||
}
|
||||
}, [currentTrain]);
|
||||
|
||||
const toggleLiveNotification = async () => {
|
||||
if (liveNotifyId) {
|
||||
await endStationLockActivity(liveNotifyId).catch(() => {});
|
||||
setLiveNotifyId(null);
|
||||
setLiveNotificationActive(false);
|
||||
} else {
|
||||
const trains = buildTrainsInfo();
|
||||
try {
|
||||
const id = await startStationLockActivity({
|
||||
stationName: station[0]?.Station_JP || "",
|
||||
nextTrainTime: trains[0]?.time || "",
|
||||
nextTrainDestination: trains[0]?.destination || "",
|
||||
nextTrainPlatform: trains[0]?.platform || "",
|
||||
followingTrainTime: trains[1]?.time || "",
|
||||
followingTrainDestination: trains[1]?.destination || "",
|
||||
stationNumber: station[0]?.StationNumber,
|
||||
lineColor,
|
||||
trains,
|
||||
});
|
||||
setLiveNotifyId(id);
|
||||
setLiveNotificationActive(true);
|
||||
} catch (_e) {}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{ display: "flex", flexDirection: "column", flex: 1 }}
|
||||
@@ -306,6 +409,31 @@ export const FixedStation: FC<props> = ({ stationID }) => {
|
||||
}}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
{isLiveActivityAvailable() && (
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
}}
|
||||
onPress={toggleLiveNotification}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: liveNotifyId ? "#FF6B00" : "#555",
|
||||
paddingHorizontal: 8,
|
||||
height: 26,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={liveNotifyId ? "notifications" : "notifications-outline"}
|
||||
size={15}
|
||||
color="white"
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
@@ -373,7 +501,7 @@ export const FixedStation: FC<props> = ({ stationID }) => {
|
||||
};
|
||||
|
||||
const FixedStationBoxEachTrain = ({ d, station, displaySize }) => {
|
||||
const { colors } = useThemeColors();
|
||||
const { colors, isDark } = useThemeColors();
|
||||
const { currentTrain } = useCurrentTrain();
|
||||
const { stationList } = useStationList();
|
||||
const { allCustomTrainData } = useAllTrainDiagram();
|
||||
@@ -392,7 +520,7 @@ const FixedStationBoxEachTrain = ({ d, station, displaySize }) => {
|
||||
useEffect(() => {
|
||||
setTrain(getCurrentTrainData(d.train, currentTrain, allCustomTrainData));
|
||||
}, [currentTrain, d.train]);
|
||||
const { name, color } = getTrainType({ type: train.type, whiteMode: true });
|
||||
const { name, color } = getTrainType({ type: train.type, whiteMode: !isDark });
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useAllTrainDiagram } from "@/stateBox/useAllTrainDiagram";
|
||||
import { useCurrentTrain } from "@/stateBox/useCurrentTrain";
|
||||
import { useStationList } from "@/stateBox/useStationList";
|
||||
import { StationProps } from "@/lib/CommonTypes";
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import { FC, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
@@ -23,6 +23,13 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import dayjs from "dayjs";
|
||||
import { useTrainMenu } from "@/stateBox/useTrainMenu";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import {
|
||||
startTrainFollowActivity,
|
||||
updateTrainFollowActivity,
|
||||
endTrainFollowActivity,
|
||||
isLiveNotificationActive,
|
||||
isAvailable as isLiveActivityAvailable,
|
||||
} from "expo-live-activity";
|
||||
|
||||
type props = {
|
||||
trainID: string;
|
||||
@@ -37,11 +44,16 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
getPosition,
|
||||
fixedPositionSize,
|
||||
setFixedPositionSize,
|
||||
liveNotificationActive,
|
||||
setLiveNotificationActive,
|
||||
} = useCurrentTrain();
|
||||
|
||||
const { mapSwitch } = useTrainMenu();
|
||||
const { allCustomTrainData, allTrainDiagram } = useAllTrainDiagram();
|
||||
|
||||
const [liveNotifyId, setLiveNotifyId] = useState<string | null>(null);
|
||||
const liveNotifyIdRef = useRef<string | null>(null);
|
||||
|
||||
const [train, setTrain] = useState<trainDataType>(null);
|
||||
const [customData, setCustomData] = useState<CustomTrainData>(
|
||||
getCurrentTrainData(trainID, currentTrain, allCustomTrainData)
|
||||
@@ -290,6 +302,98 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
? ` ${parseInt(customData.train_id) - parseInt(customData.train_num_distance)}号`
|
||||
: ""
|
||||
}`;
|
||||
|
||||
// ── Station Progress for Live Notification ──
|
||||
const stationStops = trainDataWidhThrough
|
||||
.filter((d) => d && !d.split(",")[1]?.includes("通"))
|
||||
.map((d) => d.split(",")[0]);
|
||||
|
||||
const nextStationIndex = (() => {
|
||||
const name = nextStationData[0]?.Station_JP;
|
||||
if (!name) return -1;
|
||||
const idx = stationStops.indexOf(name);
|
||||
if (idx >= 0) return idx;
|
||||
// 部分一致フォールバック
|
||||
return stationStops.findIndex((s) => s === name || name.includes(s) || s.includes(name));
|
||||
})();
|
||||
|
||||
// ── Live Notification ──
|
||||
useEffect(() => {
|
||||
liveNotifyIdRef.current = liveNotifyId;
|
||||
}, [liveNotifyId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (liveNotifyIdRef.current) {
|
||||
endTrainFollowActivity(liveNotifyIdRef.current).catch(() => {});
|
||||
setLiveNotificationActive(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!liveNotifyId || !train) return;
|
||||
const delayNum = train.delay === "入線" ? 0 : parseInt(train.delay) || 0;
|
||||
const delayStatus = delayNum > 0 ? `${delayNum}分遅れ` : "定刻";
|
||||
const positionStatus =
|
||||
nextStationData[0]?.Station_JP === train.Pos ? "ただいま" : "次は";
|
||||
updateTrainFollowActivity(liveNotifyId, {
|
||||
currentStation: train.Pos || "",
|
||||
nextStation: nextStationData[0]?.Station_JP || "",
|
||||
delayMinutes: delayNum,
|
||||
scheduledArrival: "",
|
||||
trainType: customTrainType.shortName,
|
||||
trainName: trainNameText,
|
||||
trainTypeColor: customTrainType.color,
|
||||
destination: ToData,
|
||||
positionStatus,
|
||||
delayStatus,
|
||||
stationStops,
|
||||
nextStationIndex: nextStationIndex >= 0 ? nextStationIndex : undefined,
|
||||
}).catch(() => {});
|
||||
}, [train, nextStationData, liveNotifyId, stationStops, nextStationIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (liveNotifyId && !isLiveNotificationActive()) {
|
||||
setLiveNotifyId(null);
|
||||
setLiveNotificationActive(false);
|
||||
}
|
||||
}, [currentTrain]);
|
||||
|
||||
const toggleLiveNotification = async () => {
|
||||
if (liveNotifyId) {
|
||||
await endTrainFollowActivity(liveNotifyId).catch(() => {});
|
||||
setLiveNotifyId(null);
|
||||
setLiveNotificationActive(false);
|
||||
} else {
|
||||
const delayNum =
|
||||
train?.delay === "入線" ? 0 : parseInt(train?.delay) || 0;
|
||||
const delayStatus = delayNum > 0 ? `${delayNum}分遅れ` : "定刻";
|
||||
const positionStatus =
|
||||
nextStationData[0]?.Station_JP === train?.Pos ? "ただいま" : "次は";
|
||||
try {
|
||||
const id = await startTrainFollowActivity({
|
||||
trainNumber: trainID,
|
||||
lineName: "",
|
||||
destination: ToData,
|
||||
currentStation: train?.Pos || "",
|
||||
nextStation: nextStationData[0]?.Station_JP || "",
|
||||
delayMinutes: delayNum,
|
||||
scheduledArrival: "",
|
||||
trainType: customTrainType.shortName,
|
||||
trainName: trainNameText,
|
||||
trainTypeColor: customTrainType.color,
|
||||
positionStatus,
|
||||
delayStatus,
|
||||
stationStops,
|
||||
nextStationIndex: nextStationIndex >= 0 ? nextStationIndex : undefined,
|
||||
});
|
||||
setLiveNotifyId(id);
|
||||
setLiveNotificationActive(true);
|
||||
} catch (_e) {}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{ display: "flex", flexDirection: "column", flex: 1 }}
|
||||
@@ -579,6 +683,31 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
|
||||
}}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
{isLiveActivityAvailable() && (
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
}}
|
||||
onPress={toggleLiveNotification}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: liveNotifyId ? "#FF6B00" : "#555",
|
||||
paddingHorizontal: 8,
|
||||
height: 26,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={liveNotifyId ? "notifications" : "notifications-outline"}
|
||||
size={15}
|
||||
color="white"
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
@@ -839,7 +968,7 @@ const EachStopData: FC<eachStopType> = (props) => {
|
||||
<Ionicons
|
||||
name="arrow-forward"
|
||||
size={isSmall ? 8 : 14}
|
||||
color="black"
|
||||
color={colors.icon}
|
||||
style={{ marginTop: isSmall ? 0 : 3 }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -3,15 +3,13 @@ import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
Platform,
|
||||
TouchableOpacityProps,
|
||||
TextStyle,
|
||||
} from "react-native";
|
||||
import Constants from "expo-constants";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { useTrainMenu } from "../../stateBox/useTrainMenu";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const top = Platform.OS == "ios" ? Constants.statusBarHeight : 0;
|
||||
type MapsButtonProps = {
|
||||
onPress: () => void;
|
||||
};
|
||||
@@ -23,6 +21,7 @@ type stylesType = {
|
||||
export const MapsButton: FC<MapsButtonProps> = ({ onPress }) => {
|
||||
const { fixed } = useThemeColors();
|
||||
const { mapSwitch } = useTrainMenu();
|
||||
const { top } = useSafeAreaInsets();
|
||||
const styles: stylesType = {
|
||||
touch: {
|
||||
position: "absolute",
|
||||
|
||||
@@ -6,13 +6,14 @@ import * as Updates from "expo-updates";
|
||||
import Constants from "expo-constants";
|
||||
import { useCurrentTrain } from "../../stateBox/useCurrentTrain";
|
||||
import { useTrainMenu } from "../../stateBox/useTrainMenu";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const top = Platform.OS == "ios" ? Constants.statusBarHeight : 0;
|
||||
export const NewMenu = () => {
|
||||
const { fixed } = useThemeColors();
|
||||
const { webview } = useCurrentTrain();
|
||||
const { width } = useWindowDimensions();
|
||||
const { LoadError } = useTrainMenu();
|
||||
const { top } = useSafeAreaInsets();
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
|
||||
@@ -2,15 +2,13 @@ import React, { FC } from "react";
|
||||
import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
Platform,
|
||||
TouchableOpacityProps,
|
||||
TextStyle,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import Constants from "expo-constants";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { useTrainMenu } from "../../stateBox/useTrainMenu";
|
||||
const top = Platform.OS == "ios" ? Constants.statusBarHeight : 0;
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
type stylesType = {
|
||||
touch: TouchableOpacityProps["style"];
|
||||
@@ -24,6 +22,7 @@ type ReloadButton = {
|
||||
export const ReloadButton:FC<ReloadButton> = ({ onPress, right }) => {
|
||||
const { fixed } = useThemeColors();
|
||||
const { mapSwitch, LoadError = false } = useTrainMenu();
|
||||
const { top } = useSafeAreaInsets();
|
||||
const styles: stylesType = {
|
||||
touch: {
|
||||
position: "absolute",
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import {
|
||||
ScrollView,
|
||||
View,
|
||||
Animated,
|
||||
LayoutAnimation,
|
||||
ViewStyle,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import React, {
|
||||
useEffect,
|
||||
@@ -12,9 +10,9 @@ import React, {
|
||||
useState,
|
||||
useLayoutEffect,
|
||||
ReactNode,
|
||||
useRef,
|
||||
MutableRefObject,
|
||||
} from "react";
|
||||
import { NativeViewGestureHandler } from "react-native-gesture-handler";
|
||||
import { ScrollView } from "react-native-actions-sheet";
|
||||
import { AS } from "../storageControl";
|
||||
import { STORAGE_KEYS } from "@/constants";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
@@ -29,7 +27,7 @@ type DynamicHeaderScrollViewProps = {
|
||||
topStickyContent?: ReactNode;
|
||||
styles?: { header: ViewStyle };
|
||||
from?: string;
|
||||
scrollHandlers?: any;
|
||||
scrollRef?: MutableRefObject<any>;
|
||||
};
|
||||
|
||||
export const DynamicHeaderScrollView: React.FC<DynamicHeaderScrollViewProps> = (
|
||||
@@ -43,7 +41,7 @@ export const DynamicHeaderScrollView: React.FC<DynamicHeaderScrollViewProps> = (
|
||||
topStickyContent,
|
||||
styles,
|
||||
from,
|
||||
scrollHandlers,
|
||||
scrollRef,
|
||||
} = props;
|
||||
const { fixed } = useThemeColors();
|
||||
const [headerSize, setHeaderSize] = useState("default");
|
||||
@@ -137,7 +135,6 @@ export const DynamicHeaderScrollView: React.FC<DynamicHeaderScrollViewProps> = (
|
||||
const [headerVisible, setHeaderVisible] = useState(false);
|
||||
|
||||
const onScroll = (event) => {
|
||||
scrollHandlers.onScroll(event);
|
||||
switch (headerSize) {
|
||||
case "big":
|
||||
setHeaderVisible(false);
|
||||
@@ -181,31 +178,27 @@ export const DynamicHeaderScrollView: React.FC<DynamicHeaderScrollViewProps> = (
|
||||
{topStickyContent}
|
||||
</Animated.View>
|
||||
</View>
|
||||
<NativeViewGestureHandler
|
||||
simultaneousHandlers={scrollHandlers.simultaneousHandlers}
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
nestedScrollEnabled
|
||||
bounces={false}
|
||||
style={{ zIndex: 0 }}
|
||||
stickyHeaderIndices={[1]}
|
||||
scrollEventThrottle={1}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
<ScrollView
|
||||
nestedScrollEnabled
|
||||
ref={scrollHandlers.ref}
|
||||
onLayout={scrollHandlers.onLayout}
|
||||
scrollEventThrottle={scrollHandlers.scrollEventThrottle}
|
||||
style={{ zIndex: 0 }}
|
||||
stickyHeaderIndices={[1]}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
<View style={{ height: Scroll_Distance, flexDirection: "column" }} />
|
||||
{topStickyContent && (
|
||||
<View
|
||||
style={{
|
||||
paddingTop: Min_Header_Height + 40,
|
||||
flexDirection: "column",
|
||||
}}
|
||||
//index={1}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</ScrollView>
|
||||
</NativeViewGestureHandler>
|
||||
<View style={{ height: Scroll_Distance, flexDirection: "column" }} />
|
||||
{topStickyContent && (
|
||||
<View
|
||||
style={{
|
||||
paddingTop: Min_Header_Height + 40,
|
||||
flexDirection: "column",
|
||||
}}
|
||||
//index={1}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,8 +16,8 @@ import { SimpleDot } from "../SimpleDot";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import Sortable from "react-native-sortables";
|
||||
import Animated, {
|
||||
FadeInDown,
|
||||
FadeOutDown,
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
@@ -121,6 +121,24 @@ export const CarouselBox = ({
|
||||
}
|
||||
}, [isSortMode]);
|
||||
|
||||
// ソートモード終了直後フラグ(次の listIndex 変更でアニメーションをスキップ)
|
||||
const justExitedSortRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSortMode) {
|
||||
justExitedSortRef.current = true;
|
||||
}
|
||||
}, [isSortMode]);
|
||||
|
||||
// バッジからのインデックス変更をカルーセルに反映
|
||||
useEffect(() => {
|
||||
if (listIndex >= 0 && carouselRef.current) {
|
||||
const animated = !justExitedSortRef.current;
|
||||
justExitedSortRef.current = false;
|
||||
carouselRef.current.scrollTo({ index: listIndex, animated });
|
||||
}
|
||||
}, [listIndex]);
|
||||
|
||||
// ドットのスクロール追従
|
||||
useEffect(() => {
|
||||
if (!carouselBadgeScrollViewRef.current) return;
|
||||
@@ -251,7 +269,7 @@ export const CarouselBox = ({
|
||||
{isGridMounted && (
|
||||
<Animated.View
|
||||
style={[
|
||||
{ position: "absolute", width, paddingHorizontal: gridPad, overflow: "visible" },
|
||||
{ position: "absolute", width, height: gridHeight, paddingHorizontal: gridPad, overflow: "visible" },
|
||||
gridAnimStyle,
|
||||
]}
|
||||
>
|
||||
@@ -314,8 +332,8 @@ export const CarouselBox = ({
|
||||
{/* 並び替えコントロール:ソートモード時に最下部からスライドイン */}
|
||||
{isSortMode && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(300)}
|
||||
exiting={FadeOutDown.duration(200)}
|
||||
entering={FadeIn.duration(200)}
|
||||
exiting={FadeOut.duration(150)}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -3,8 +3,8 @@ import React from "react";
|
||||
import {
|
||||
TouchableOpacity,
|
||||
Text,
|
||||
View,
|
||||
LayoutAnimation,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import Ionicons from "react-native-vector-icons/Ionicons";
|
||||
@@ -64,11 +64,7 @@ export const CarouselTypeChanger = ({
|
||||
setMapMode(false);
|
||||
};
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior="position"
|
||||
contentContainerStyle={{ flex: 1, flexDirection: "row" }}
|
||||
keyboardVerticalOffset={mapMode ? 0 : 45}
|
||||
enabled={Platform.OS === "ios"}
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 40,
|
||||
@@ -191,6 +187,6 @@ export const CarouselTypeChanger = ({
|
||||
お気に入りリスト
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { FC } from "react";
|
||||
import { Text, TouchableOpacity, View } from "react-native";
|
||||
import lineColorList from "@/assets/originData/lineColorList";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { lightColors, useThemeColors } from "@/lib/theme";
|
||||
|
||||
type Props = {
|
||||
item: any[]; // StationProps[] (路線をまたぐ同名駅の配列)
|
||||
@@ -16,7 +16,7 @@ type Props = {
|
||||
* hooks・Lottie・前後駅計算などを一切持たない純粋な表示コンポーネント。
|
||||
*/
|
||||
export const GridMiniSign: FC<Props> = React.memo(({ item, width, height, onPress }) => {
|
||||
const { colors, fixed } = useThemeColors();
|
||||
const { fixed } = useThemeColors();
|
||||
const station = item[0];
|
||||
const lineId = station.StationNumber?.slice(0, 1) ?? "Y";
|
||||
const lineNum = station.StationNumber?.slice(1) ?? "";
|
||||
@@ -33,7 +33,7 @@ export const GridMiniSign: FC<Props> = React.memo(({ item, width, height, onPres
|
||||
height,
|
||||
borderColor: fixed.primary,
|
||||
borderWidth: 1,
|
||||
backgroundColor: colors.background,
|
||||
backgroundColor: lightColors.background,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
@@ -48,7 +48,7 @@ export const GridMiniSign: FC<Props> = React.memo(({ item, width, height, onPres
|
||||
borderRadius: height * 0.14,
|
||||
borderColor: lineColor,
|
||||
borderWidth: 2,
|
||||
backgroundColor: colors.background,
|
||||
backgroundColor: lightColors.background,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
@@ -57,7 +57,7 @@ export const GridMiniSign: FC<Props> = React.memo(({ item, width, height, onPres
|
||||
style={{
|
||||
fontSize: height * 0.1,
|
||||
fontWeight: "bold",
|
||||
color: colors.text,
|
||||
color: lightColors.text,
|
||||
textAlign: "center",
|
||||
lineHeight: height * 0.12,
|
||||
}}
|
||||
@@ -84,7 +84,7 @@ export const GridMiniSign: FC<Props> = React.memo(({ item, width, height, onPres
|
||||
style={{
|
||||
fontSize: nameFontSize,
|
||||
fontWeight: "bold",
|
||||
color: "#005170",
|
||||
color: lightColors.textStationName,
|
||||
textAlign: "center",
|
||||
}}
|
||||
adjustsFontSizeToFit
|
||||
@@ -95,7 +95,7 @@ export const GridMiniSign: FC<Props> = React.memo(({ item, width, height, onPres
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 8,
|
||||
color: "#005170",
|
||||
color: lightColors.textStationName,
|
||||
textAlign: "center",
|
||||
marginTop: 2,
|
||||
}}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { useItemContext } from "react-native-sortables";
|
||||
import Animated, {
|
||||
Easing,
|
||||
interpolate,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withDelay,
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { GridMiniSign } from "./GridMiniSign";
|
||||
|
||||
@@ -13,17 +14,21 @@ type Props = {
|
||||
item: any;
|
||||
cellW: number;
|
||||
cellH: number;
|
||||
startX: number; // 入場開始位置(カルーセル上の元座標)
|
||||
startX: number;
|
||||
startY: number;
|
||||
exitX: number; // 退場先位置(タップ選択後のカルーセル座標)
|
||||
exitX: number;
|
||||
exitY: number;
|
||||
startScale: number;
|
||||
isCurrentCard: boolean;
|
||||
isExiting: boolean;
|
||||
exitDelay: number;
|
||||
onPress?: () => void;
|
||||
};
|
||||
|
||||
/** カルーセル中央 → グリッドセルへ飛ぶアニメーション付きカード */
|
||||
const EASE_OUT = Easing.out(Easing.cubic);
|
||||
const DURATION = 320;
|
||||
|
||||
/** グリッドセルへのスライド+スケールアニメーション付きカード */
|
||||
export const SortGridCard = React.memo(function SortGridCard({
|
||||
item,
|
||||
cellW,
|
||||
@@ -33,35 +38,48 @@ export const SortGridCard = React.memo(function SortGridCard({
|
||||
exitX,
|
||||
exitY,
|
||||
startScale,
|
||||
isCurrentCard,
|
||||
isExiting,
|
||||
exitDelay,
|
||||
onPress,
|
||||
}: Props) {
|
||||
const { activationAnimationProgress } = useItemContext();
|
||||
|
||||
const tx = useSharedValue(startX);
|
||||
const ty = useSharedValue(startY);
|
||||
const sc = useSharedValue(startScale);
|
||||
// 現在選択中のカードはカルーセル位置から、それ以外は近距離からスライド
|
||||
const initX = isCurrentCard ? startX : startX * 0.35;
|
||||
const initY = isCurrentCard ? startY : startY * 0.35;
|
||||
const initScale = isCurrentCard ? Math.min(startScale, 1.5) : Math.min(startScale, 1.15);
|
||||
|
||||
// 入場: カルーセル位置からグリッドセル位置へ
|
||||
const tx = useSharedValue(initX);
|
||||
const ty = useSharedValue(initY);
|
||||
const sc = useSharedValue(initScale);
|
||||
const opacity = useSharedValue(0);
|
||||
|
||||
// 入場
|
||||
useEffect(() => {
|
||||
tx.value = withSpring(0, { damping: 16, stiffness: 110 });
|
||||
ty.value = withSpring(0, { damping: 16, stiffness: 110 });
|
||||
sc.value = withSpring(1, { damping: 16, stiffness: 110 });
|
||||
const cfg = { duration: DURATION, easing: EASE_OUT };
|
||||
tx.value = withTiming(0, cfg);
|
||||
ty.value = withTiming(0, cfg);
|
||||
sc.value = withTiming(1, cfg);
|
||||
opacity.value = withTiming(1, { duration: 180, easing: EASE_OUT });
|
||||
}, []);
|
||||
|
||||
// 退場: グリッドセル位置からカルーセル位置へ戻る
|
||||
// 退場
|
||||
useEffect(() => {
|
||||
if (!isExiting) return;
|
||||
tx.value = withDelay(exitDelay, withSpring(exitX, { damping: 16, stiffness: 110 }));
|
||||
ty.value = withDelay(exitDelay, withSpring(exitY, { damping: 16, stiffness: 110 }));
|
||||
sc.value = withDelay(exitDelay, withSpring(startScale, { damping: 16, stiffness: 110 }));
|
||||
const cfg = { duration: DURATION, easing: EASE_OUT };
|
||||
const toX = isCurrentCard ? exitX : exitX * 0.35;
|
||||
const toY = isCurrentCard ? exitY : exitY * 0.35;
|
||||
tx.value = withDelay(exitDelay, withTiming(toX, cfg));
|
||||
ty.value = withDelay(exitDelay, withTiming(toY, cfg));
|
||||
sc.value = withDelay(exitDelay, withTiming(initScale, cfg));
|
||||
opacity.value = withDelay(exitDelay, withTiming(0, { duration: 150, easing: EASE_OUT }));
|
||||
}, [isExiting]);
|
||||
|
||||
const animStyle = useAnimatedStyle(() => {
|
||||
const p = activationAnimationProgress.value;
|
||||
return {
|
||||
opacity: interpolate(p, [0, 1], [1, 0.85]),
|
||||
opacity: opacity.value * interpolate(p, [0, 1], [1, 0.85]),
|
||||
shadowOpacity: interpolate(p, [0, 1], [0, 0.4]),
|
||||
shadowRadius: interpolate(p, [0, 1], [0, 10]),
|
||||
elevation: interpolate(p, [0, 1], [1, 12]),
|
||||
|
||||
@@ -72,7 +72,6 @@ export function useSortMode({
|
||||
({ item, index }: { item: any; index: number }) => {
|
||||
const col = index % cols;
|
||||
const row = Math.floor(index / cols);
|
||||
// カルーセルでの card 中心位置 → グリッドセルの中心位置 との差分を初期オフセットに
|
||||
const carouselCardCenterX =
|
||||
(index - sortModeStartIndexRef.current) * width + width / 2 - gridPad;
|
||||
const carouselCardCenterY = carouselHeight / 2;
|
||||
@@ -80,11 +79,10 @@ export function useSortMode({
|
||||
const cellCenterY = row * (cellH + gridGap) + cellH / 2;
|
||||
const startX = carouselCardCenterX - cellCenterX;
|
||||
const startY = carouselCardCenterY - cellCenterY;
|
||||
// 退場先: タップで選択されたカードが画面中央に来るカルーセル配置
|
||||
const exitCarouselCardCenterX =
|
||||
(index - exitTargetIndexRef.current) * width + width / 2 - gridPad;
|
||||
const exitX = exitCarouselCardCenterX - cellCenterX;
|
||||
const exitY = carouselCardCenterY - cellCenterY; // Y は変わらない
|
||||
const exitY = carouselCardCenterY - cellCenterY;
|
||||
return (
|
||||
<SortGridCard
|
||||
key={item[0].StationNumber}
|
||||
@@ -96,6 +94,7 @@ export function useSortMode({
|
||||
exitX={exitX}
|
||||
exitY={exitY}
|
||||
startScale={origW / cellW}
|
||||
isCurrentCard={index === sortModeStartIndexRef.current}
|
||||
isExiting={uiMode === "sort-exiting"}
|
||||
exitDelay={Math.min(index * 40, 180)}
|
||||
onPress={() => {
|
||||
@@ -106,7 +105,7 @@ export function useSortMode({
|
||||
);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[cellW, cellH, gridGap, gridPad, origW, origH, width, carouselHeight, uiMode]
|
||||
[cellW, cellH, gridGap, gridPad, origW, width, carouselHeight, uiMode]
|
||||
);
|
||||
|
||||
/** Sortable.Grid の onDragEnd */
|
||||
|
||||
@@ -15,10 +15,12 @@ import { SheetManager } from "react-native-actions-sheet";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { useNotification } from "@/stateBox/useNotifications";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { isAvailable as isFelicaAvailable } from "@/modules/expo-felica-reader/src";
|
||||
|
||||
export const FixedContentBottom = (props) => {
|
||||
const { expoPushToken } = useNotification();
|
||||
const { colors, fixed } = useThemeColors();
|
||||
const felicaAvailable = isFelicaAvailable();
|
||||
return (
|
||||
<>
|
||||
{props.children}
|
||||
@@ -156,14 +158,16 @@ export const FixedContentBottom = (props) => {
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: "#00796B",
|
||||
backgroundColor: felicaAvailable ? "#00796B" : "#9E9E9E",
|
||||
borderColor: fixed.primary,
|
||||
padding: 10,
|
||||
borderWidth: 1,
|
||||
margin: 2,
|
||||
alignItems: "center",
|
||||
opacity: felicaAvailable ? 1 : 0.6,
|
||||
}}
|
||||
onPress={() => props.navigate("setting", { screen: "FelicaHistoryPage" })}
|
||||
disabled={!felicaAvailable}
|
||||
>
|
||||
<Text style={{ color: "white", fontWeight: "bold", fontSize: 20 }}>
|
||||
IC残高・履歴
|
||||
@@ -171,6 +175,11 @@ export const FixedContentBottom = (props) => {
|
||||
<MaterialCommunityIcons name="contactless-payment" color="white" size={50} />
|
||||
<Text style={{ color: "white" }}>Felica対応ICカードの</Text>
|
||||
<Text style={{ color: "white" }}>残高・利用履歴を表示</Text>
|
||||
{!felicaAvailable && (
|
||||
<Text style={{ color: "white", fontSize: 11, marginTop: 2 }}>
|
||||
NFC非対応端末
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<TextBox
|
||||
@@ -185,7 +194,7 @@ export const FixedContentBottom = (props) => {
|
||||
JR四国のSNS一覧です。
|
||||
</Text>
|
||||
</TextBox>
|
||||
<Text style={{ fontWeight: "bold", fontSize: 20 }}>
|
||||
<Text style={{ fontWeight: "bold", fontSize: 20, color: colors.text }}>
|
||||
JR四国非公式列車データベース(β)
|
||||
</Text>
|
||||
<View style={{ flexDirection: "row" }}>
|
||||
@@ -258,7 +267,7 @@ export const FixedContentBottom = (props) => {
|
||||
<MaterialCommunityIcons name="chart-gantt" color="white" size={40} />
|
||||
</TextBox>
|
||||
</View>
|
||||
<Text style={{ fontWeight: "bold", fontSize: 20 }}>その他</Text>
|
||||
<Text style={{ fontWeight: "bold", fontSize: 20, color: colors.text }}>その他</Text>
|
||||
<TextBox
|
||||
backgroundColor="rgb(88, 101, 242)"
|
||||
flex={1}
|
||||
|
||||
@@ -88,16 +88,16 @@ export const JRSTraInfoBox = () => {
|
||||
style={{ flexDirection: "row" }}
|
||||
key={data[1] + "key" + index}
|
||||
>
|
||||
<Text style={{ flex: 15, fontSize: 18 }}>
|
||||
<Text style={{ flex: 15, fontSize: 18, color: colors.text }}>
|
||||
{data[0].replace("\n", "")}
|
||||
</Text>
|
||||
<Text style={{ flex: 5, fontSize: 18 }}>{data[1]}</Text>
|
||||
<Text style={{ flex: 6, fontSize: 18 }}>{data[3]}</Text>
|
||||
<Text style={{ flex: 5, fontSize: 18, color: colors.text }}>{data[1]}</Text>
|
||||
<Text style={{ flex: 6, fontSize: 18, color: colors.text }}>{data[3]}</Text>
|
||||
</View>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Text>現在、5分以上の遅れはありません。</Text>
|
||||
<Text style={{ color: colors.text }}>現在、5分以上の遅れはありません。</Text>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
TouchableOpacity,
|
||||
Text,
|
||||
View,
|
||||
LayoutAnimation,
|
||||
TextInput,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Keyboard,
|
||||
} from "react-native";
|
||||
import Ionicons from "react-native-vector-icons/Ionicons";
|
||||
import { useWindowDimensions } from "react-native";
|
||||
@@ -29,13 +29,27 @@ export const SearchUnitBox = ({
|
||||
const isSearch = stationSource.type === "search";
|
||||
const query = isSearch ? stationSource.query : "";
|
||||
const lineId = isSearch ? stationSource.lineId : undefined;
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const showSub = Keyboard.addListener("keyboardDidShow", (e) => {
|
||||
setKeyboardHeight(e.endCoordinates.height);
|
||||
});
|
||||
const hideSub = Keyboard.addListener("keyboardDidHide", () => {
|
||||
setKeyboardHeight(0);
|
||||
});
|
||||
return () => {
|
||||
showSub.remove();
|
||||
hideSub.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: isSearch ? 0 : 60,
|
||||
bottom: isSearch ? (Platform.OS === "ios" ? keyboardHeight : 0) : 60,
|
||||
right: 0,
|
||||
padding: isSearch ? 5 : 10,
|
||||
margin: isSearch ? 0 : 10,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { FC, useState } from "react";
|
||||
import { View, Text, TouchableOpacity } from "react-native";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { lightColors } from "@/lib/theme";
|
||||
import { useInterval } from "@/lib/useInterval";
|
||||
|
||||
import lineColorList from "@/assets/originData/lineColorList";
|
||||
@@ -12,7 +12,6 @@ type StationNumberProps = {
|
||||
};
|
||||
export const StationNumber: FC<StationNumberProps> = (props) => {
|
||||
const { currentStation, active, onPress } = props;
|
||||
const { colors } = useThemeColors();
|
||||
const [animation, setAnimation] = useState(0);
|
||||
const data = currentStation.filter((d) => (d.StationNumber ? true : false));
|
||||
useInterval(() => {
|
||||
@@ -59,7 +58,7 @@ export const StationNumber: FC<StationNumberProps> = (props) => {
|
||||
width: size,
|
||||
height: size,
|
||||
borderColor: lineColorList[lineID],
|
||||
backgroundColor: colors.background,
|
||||
backgroundColor: lightColors.background,
|
||||
borderWidth: active ? 2 : 1,
|
||||
borderRadius: 22,
|
||||
}}
|
||||
@@ -72,7 +71,7 @@ export const StationNumber: FC<StationNumberProps> = (props) => {
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
textAlign: "center",
|
||||
color: colors.text,
|
||||
color: lightColors.text,
|
||||
fontWeight: active ? "bold" : "normal",
|
||||
textAlignVertical: "center",
|
||||
}}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { View, TouchableOpacity, Linking,Platform, Image, useWindowDimensions } from "react-native";
|
||||
import Constants from "expo-constants";
|
||||
import { View, TouchableOpacity, Linking, Platform, Image, useWindowDimensions } from "react-native";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
|
||||
export const TitleBar = () => {
|
||||
const { width } = useWindowDimensions();
|
||||
const { colors } = useThemeColors();
|
||||
const { top } = useSafeAreaInsets();
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@@ -15,7 +16,7 @@ export const TitleBar = () => {
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 100,
|
||||
paddingTop: Platform.OS == "ios" ? Constants.statusBarHeight : 0,
|
||||
paddingTop: top,
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -91,7 +91,7 @@ const DataSourceAccordionCard: React.FC<DataSourceAccordionCardProps> = ({
|
||||
onPress={() => setExpanded((v) => !v)}
|
||||
activeOpacity={0.6}
|
||||
>
|
||||
<Text style={styles.accordionToggleLabel}>
|
||||
<Text style={[styles.accordionToggleLabel, { color: colors.textSecondary }]}>
|
||||
{expanded ? "詳細を閉じる" : (detailLabel ?? `${title} について`)}
|
||||
</Text>
|
||||
<MaterialCommunityIcons
|
||||
@@ -103,18 +103,18 @@ const DataSourceAccordionCard: React.FC<DataSourceAccordionCardProps> = ({
|
||||
|
||||
{/* ── 展開コンテンツ ── */}
|
||||
{expanded && (
|
||||
<View style={[styles.accordionBody, { borderTopColor: colors.borderCard }]}>
|
||||
<View style={[styles.accordionBody, { borderTopColor: colors.borderCard, backgroundColor: colors.backgroundTertiary }]}>
|
||||
{/* 説明文 */}
|
||||
<Text style={styles.bodyDesc}>{description}</Text>
|
||||
<Text style={[styles.bodyDesc, { color: colors.textSecondary }]}>{description}</Text>
|
||||
|
||||
{/* 機能リスト */}
|
||||
<View style={[styles.bodyFeatures, { borderTopColor: colors.borderSecondary }]}>
|
||||
{features.map((f) => (
|
||||
<View key={f.icon} style={styles.featureRow}>
|
||||
<View style={styles.featureIcon}>
|
||||
<MaterialCommunityIcons name={f.icon as any} size={14} color="#444" />
|
||||
<MaterialCommunityIcons name={f.icon as any} size={14} color={colors.iconSecondary} />
|
||||
</View>
|
||||
<Text style={styles.featureLabel}>{f.label}</Text>
|
||||
<Text style={[styles.featureLabel, { color: colors.textPrimary }]}>{f.label}</Text>
|
||||
<Text style={[styles.featureText, { color: colors.textSecondary }]}>{f.text}</Text>
|
||||
</View>
|
||||
))}
|
||||
@@ -126,7 +126,7 @@ const DataSourceAccordionCard: React.FC<DataSourceAccordionCardProps> = ({
|
||||
onPress={() => Linking.openURL(linkUrl)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons name="open-in-new" size={13} color="#555" />
|
||||
<MaterialCommunityIcons name="open-in-new" size={13} color={colors.iconSecondary} />
|
||||
<Text style={[styles.bodyLinkText, { color: colors.textSecondary }]}>{linkLabel}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -193,8 +193,8 @@ export const DataSourceSettings = () => {
|
||||
/>
|
||||
{!canAccess ? (
|
||||
<View style={[styles.noPermissionContainer, { backgroundColor: colors.backgroundSecondary }]}>
|
||||
<Text style={styles.noPermissionText}>この設定にアクセスする権限がありません。</Text>
|
||||
<Text style={styles.noPermissionSubText}>鉄道運用Hubまたはアプリ管理者の権限が必要です。</Text>
|
||||
<Text style={[styles.noPermissionText, { color: colors.textPrimary }]}>この設定にアクセスする権限がありません。</Text>
|
||||
<Text style={[styles.noPermissionSubText, { color: colors.textSecondary }]}>鉄道運用Hubまたはアプリ管理者の権限が必要です。</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView style={[styles.content, { backgroundColor: colors.backgroundSecondary }]} contentContainerStyle={styles.contentInner}>
|
||||
@@ -230,8 +230,8 @@ export const DataSourceSettings = () => {
|
||||
linkUrl="https://www.elesite-next.com/"
|
||||
/>
|
||||
|
||||
<View style={styles.infoSection}>
|
||||
<Text style={styles.infoText}>
|
||||
<View style={[styles.infoSection, { backgroundColor: colors.backgroundTertiary }]}>
|
||||
<Text style={[styles.infoText, { color: colors.textCaution }]}>
|
||||
外部のコミュニティデータソースとの連携を管理します。
|
||||
{"\n\n"}
|
||||
データの正確性は保証されません。また、これらの連携情報を利用する時点でそれぞれのサイトの利用規約に同意したものとします。{"\n\n"}外部ソースはJR四国非公式アプリが管理していないデータであるため、お問い合わせは各サービスの窓口までお願いいたします。
|
||||
|
||||
@@ -65,7 +65,7 @@ export const FavoriteSettingsItem = ({ currentStation }) => {
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 20 }}>{currentStation[0].Station_JP}</Text>
|
||||
<Text style={{ fontSize: 20, color: colors.text }}>{currentStation[0].Station_JP}</Text>
|
||||
<View style={{ flex: 1 }} />
|
||||
</View>
|
||||
<View
|
||||
@@ -75,7 +75,7 @@ export const FavoriteSettingsItem = ({ currentStation }) => {
|
||||
alignSelf: "center",
|
||||
}}
|
||||
>
|
||||
<Ionicons name={"reorder-two"} size={20} style={{ marginHorizontal: 10 }} />
|
||||
<Ionicons name={"reorder-two"} size={20} color={colors.text} style={{ marginHorizontal: 10 }} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { BigButton } from "../atom/BigButton";
|
||||
import { SheetHeaderItem } from "../atom/SheetHeaderItem";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import * as ExpoFelicaReader from "../../modules/expo-felica-reader/src";
|
||||
import { saveWidgetData } from "@/modules/expo-felica-reader/src";
|
||||
import type { FelicaCardInfo, FelicaHistoryEntry } from "../../modules/expo-felica-reader/src";
|
||||
import { lookupFelicaStation } from "../../lib/felicaStationMap";
|
||||
import { AS } from "../../storageControl";
|
||||
@@ -119,7 +120,7 @@ function HistoryRow({ entry, index, prevBalance }: { entry: FelicaHistoryEntry;
|
||||
onLongPress={() => {
|
||||
Clipboard.setStringAsync(debugText);
|
||||
}}
|
||||
style={[styles.row, { borderBottomColor: colors.border }, index % 2 === 0 ? { backgroundColor: colors.background } : styles.rowOdd]}
|
||||
style={[styles.row, { borderBottomColor: colors.border, backgroundColor: index % 2 === 0 ? colors.background : colors.backgroundSecondary }]}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.rowLeft}>
|
||||
@@ -165,11 +166,18 @@ export function FelicaHistoryPage() {
|
||||
systemCode: data.systemCode,
|
||||
scannedAt: new Date().toLocaleString("ja-JP"),
|
||||
});
|
||||
// iOS ウィジェットにも残高データを同期
|
||||
saveWidgetData("felicaLastSnapshot", {
|
||||
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",
|
||||
widgetName: "JR_shikoku_felica_balance",
|
||||
renderWidget: async () => {
|
||||
const quickData = await getFelicaQuickAccessData();
|
||||
return <FelicaQuickAccessWidget {...quickData} />;
|
||||
@@ -203,8 +211,8 @@ export function FelicaHistoryPage() {
|
||||
|
||||
{/* エラー */}
|
||||
{error && (
|
||||
<View style={styles.errorBox}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
<View style={[styles.errorBox, { backgroundColor: colors.backgroundSecondary }]}>
|
||||
<Text style={[styles.errorText, { color: colors.textError }]}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -212,7 +220,7 @@ export function FelicaHistoryPage() {
|
||||
{result && (
|
||||
<>
|
||||
{/* 残高カード */}
|
||||
<View style={[styles.balanceCard, { borderColor: fixed.primary }]}>
|
||||
<View style={[styles.balanceCard, { borderColor: fixed.primary, backgroundColor: colors.backgroundSecondary }]}>
|
||||
<Text style={[styles.cardTypeText, { color: colors.textAccent }]}>{cardTypeLabel(result.idm)}</Text>
|
||||
<Text style={[styles.balanceLabel, { color: colors.textSecondary }]}>残高</Text>
|
||||
<Text style={[styles.balanceAmount, { color: colors.textAccent }]}>
|
||||
|
||||
@@ -134,7 +134,7 @@ const SimpleSwitch = ({ bool, setBool, str }) => {
|
||||
borderColor: "white",
|
||||
alignContent: "center",
|
||||
}}
|
||||
textStyle={{ fontSize: 20, fontWeight: "normal" }}
|
||||
textStyle={{ fontSize: 20, fontWeight: "normal", color: colors.text }}
|
||||
title={str}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -94,7 +94,7 @@ const SimpleSwitch = ({ bool, setBool, str }) => {
|
||||
borderColor: "white",
|
||||
alignContent: "center",
|
||||
}}
|
||||
textStyle={{ fontSize: 20, fontWeight: "normal" }}
|
||||
textStyle={{ fontSize: 20, fontWeight: "normal", color: colors.text }}
|
||||
title={str}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -120,12 +120,12 @@ export const SettingTopPage = ({
|
||||
</TouchableOpacity>
|
||||
<View style={{ flexDirection: "row", paddingTop: 10 }}>
|
||||
<View style={{ flex: 1 }} />
|
||||
<Text>内部バージョン: {versionCode}</Text>
|
||||
<Text style={{ color: colors.text }}>内部バージョン: {versionCode}</Text>
|
||||
<View style={{ flex: 1 }} />
|
||||
</View>
|
||||
<View style={{ flexDirection: "row", paddingBottom: 10 }}>
|
||||
<View style={{ flex: 1 }} />
|
||||
<Text>ReleaseChannel: {Updates.channel}</Text>
|
||||
<Text style={{ color: colors.text }}>ReleaseChannel: {Updates.channel}</Text>
|
||||
<View style={{ flex: 1 }} />
|
||||
</View>
|
||||
|
||||
@@ -134,6 +134,7 @@ export const SettingTopPage = ({
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontStyle: "italic",
|
||||
color: colors.text,
|
||||
}}
|
||||
>
|
||||
このアプリは、四国旅客鉄道株式会社の提供する列車走行位置表示システムを利用し、HARUKIN/Xprocessにより一部の機能を拡張したものです。
|
||||
@@ -142,6 +143,7 @@ export const SettingTopPage = ({
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontStyle: "italic",
|
||||
color: colors.text,
|
||||
}}
|
||||
>
|
||||
このアプリに関するお問い合わせは、HARUKIN/Xprocessにお願いします。くれぐれも四国旅客鉄道株式会社にはお問い合わせしないようにお願いします。
|
||||
@@ -262,6 +264,7 @@ export const SettingTopPage = ({
|
||||
};
|
||||
|
||||
const SettingList = ({ string, onPress, disabled }) => {
|
||||
const { colors } = useThemeColors();
|
||||
return (
|
||||
<ListItem
|
||||
activeScale={0.95}
|
||||
@@ -269,9 +272,11 @@ const SettingList = ({ string, onPress, disabled }) => {
|
||||
bottomDivider
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
// @ts-ignore: containerStyle type mismatch with TouchableScale Component
|
||||
containerStyle={{ backgroundColor: colors.surface }}
|
||||
>
|
||||
<ListItem.Content>
|
||||
<ListItem.Title>{string}</ListItem.Title>
|
||||
<ListItem.Title style={{ color: colors.text }}>{string}</ListItem.Title>
|
||||
</ListItem.Content>
|
||||
<ListItem.Chevron />
|
||||
</ListItem>
|
||||
|
||||
@@ -1,35 +1,31 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { View, Text, TouchableOpacity, ScrollView } from "react-native";
|
||||
import { View, Text, ScrollView } from "react-native";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { CheckBox } from "@rneui/themed";
|
||||
import { getWidgetInfo, WidgetPreview } from "react-native-android-widget";
|
||||
import { WidgetPreview } from "react-native-android-widget";
|
||||
import { getDelayData } from "../AndroidWidget/TraInfoEXWidget";
|
||||
import { getInfoString } from "../AndroidWidget/InfoWidget";
|
||||
import { AS } from "../../storageControl";
|
||||
import {
|
||||
getFelicaQuickAccessData,
|
||||
} from "../AndroidWidget/FelicaQuickAccessWidget";
|
||||
import { getShortcutData } from "../AndroidWidget/ShortcutWidget";
|
||||
import { nameToWidget } from "../AndroidWidget/widget-task-handler";
|
||||
import { SheetHeaderItem } from "../atom/SheetHeaderItem";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
|
||||
export const WidgetSettings = () => {
|
||||
const { JR_shikoku_train_info, Info_Widget } = nameToWidget;
|
||||
const {
|
||||
JR_shikoku_train_info,
|
||||
Info_Widget,
|
||||
JR_shikoku_apps_shortcut,
|
||||
JR_shikoku_felica_balance,
|
||||
} = nameToWidget;
|
||||
const { goBack } = useNavigation();
|
||||
const { colors, fixed } = useThemeColors();
|
||||
const [time, setTime] = useState();
|
||||
const [delayString, setDelayString] = useState();
|
||||
const [trainInfo, setTrainInfo] = useState();
|
||||
const [widgetList, setWidgetList] = useState([]);
|
||||
const reload = async () => {
|
||||
const d = [];
|
||||
const data = await getWidgetInfo("JR_shikoku_train_info");
|
||||
data.forEach((elem) => {
|
||||
d.push(elem.widgetId);
|
||||
});
|
||||
setWidgetList(d);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, []);
|
||||
const [time, setTime] = useState("");
|
||||
const [delayString, setDelayString] = useState<string[] | null>(null);
|
||||
const [trainInfo, setTrainInfo] = useState("");
|
||||
const [felicaData, setFelicaData] = useState({ nowText: "", amountText: "---", detailText: "" });
|
||||
const [shortcutData, setShortcutData] = useState({ nowText: "" });
|
||||
|
||||
useEffect(() => {
|
||||
getDelayData().then(({ time, delayString }) => {
|
||||
@@ -37,10 +33,12 @@ export const WidgetSettings = () => {
|
||||
setDelayString(delayString);
|
||||
});
|
||||
getInfoString().then(({ time, text }) => {
|
||||
setTime(time);
|
||||
setTrainInfo(text.toString());
|
||||
setTrainInfo(text ? text.toString() : "");
|
||||
});
|
||||
getFelicaQuickAccessData().then(setFelicaData);
|
||||
setShortcutData(getShortcutData());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={{ height: "100%", backgroundColor: fixed.primary }}>
|
||||
<SheetHeaderItem
|
||||
@@ -48,154 +46,77 @@ export const WidgetSettings = () => {
|
||||
LeftItem={{ title: "< 設定", onPress: goBack }}
|
||||
/>
|
||||
<ScrollView style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<View style={{ alignContent: "center", alignItems: "center" }}>
|
||||
<View
|
||||
style={{
|
||||
borderRadius: 15,
|
||||
borderColor: "black",
|
||||
borderWidth: 5,
|
||||
borderStyle: "solid",
|
||||
overflow: "hidden",
|
||||
margin: 10,
|
||||
}}
|
||||
>
|
||||
<WidgetPreview
|
||||
renderWidget={() => (
|
||||
<JR_shikoku_train_info time={time} delayString={delayString} />
|
||||
)}
|
||||
width={400}
|
||||
height={250}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
borderRadius: 15,
|
||||
borderColor: "black",
|
||||
borderWidth: 5,
|
||||
borderStyle: "solid",
|
||||
overflow: "hidden",
|
||||
margin: 10,
|
||||
}}
|
||||
>
|
||||
<WidgetPreview
|
||||
renderWidget={() => <Info_Widget time={time} text={trainInfo} />}
|
||||
width={400}
|
||||
height={250}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View key={"default"} style={{ flexDirection: "row", padding: 8, borderBottomWidth: 1, borderBottomColor: colors.border }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 20,
|
||||
alignItems: "center",
|
||||
alignContent: "center",
|
||||
textAlign: "center",
|
||||
textAlignVertical: "center",
|
||||
marginRight: 10,
|
||||
}}
|
||||
>
|
||||
ID
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 20,
|
||||
alignItems: "center",
|
||||
alignContent: "center",
|
||||
textAlign: "center",
|
||||
textAlignVertical: "center",
|
||||
}}
|
||||
>
|
||||
名前
|
||||
</Text>
|
||||
</View>
|
||||
{widgetList.map((id) => (
|
||||
<WidgetList id={id} key={id} />
|
||||
))}
|
||||
<Text style={{ fontSize: 14, color: colors.text, padding: 12 }}>
|
||||
ホーム画面に追加できるウィジェット一覧です。長押しでホーム画面に追加してください。
|
||||
</Text>
|
||||
|
||||
<WidgetPreviewCard label="列車遅延速報EX" colors={colors}>
|
||||
<WidgetPreview
|
||||
renderWidget={() => (
|
||||
<JR_shikoku_train_info time={time} delayString={delayString} />
|
||||
)}
|
||||
width={400}
|
||||
height={250}
|
||||
/>
|
||||
</WidgetPreviewCard>
|
||||
|
||||
<WidgetPreviewCard label="運行情報" colors={colors}>
|
||||
<WidgetPreview
|
||||
renderWidget={() => <Info_Widget time={time} text={trainInfo} />}
|
||||
width={400}
|
||||
height={250}
|
||||
/>
|
||||
</WidgetPreviewCard>
|
||||
|
||||
<WidgetPreviewCard label="クイックアクセス" colors={colors}>
|
||||
<WidgetPreview
|
||||
renderWidget={() => <JR_shikoku_apps_shortcut {...shortcutData} />}
|
||||
width={400}
|
||||
height={250}
|
||||
/>
|
||||
</WidgetPreviewCard>
|
||||
|
||||
<WidgetPreviewCard label="ICカード残高" colors={colors}>
|
||||
<WidgetPreview
|
||||
renderWidget={() => <JR_shikoku_felica_balance {...felicaData} />}
|
||||
width={400}
|
||||
height={200}
|
||||
/>
|
||||
</WidgetPreviewCard>
|
||||
</ScrollView>
|
||||
<Text
|
||||
style={{
|
||||
backgroundColor: colors.background,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
>
|
||||
ホーム画面に追加したウィジェットをリストアップします。現状は数を表示するだけですが、ここに各種設定を追加していく予定です。
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const SimpleSwitch = ({ bool, setBool, str }) => {
|
||||
const { colors } = useThemeColors();
|
||||
return (
|
||||
<View style={{ flexDirection: "row" }}>
|
||||
<CheckBox
|
||||
checked={bool == "true" ? true : false}
|
||||
checkedColor={colors.switchActive}
|
||||
onPress={() => setBool(bool == "true" ? "false" : "true")}
|
||||
containerStyle={{
|
||||
flex: 1,
|
||||
backgroundColor: "#00000000",
|
||||
borderColor: "white",
|
||||
alignContent: "center",
|
||||
const WidgetPreviewCard = ({
|
||||
label,
|
||||
colors,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
colors: any;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<View style={{ alignItems: "center", marginBottom: 16 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
color: colors.text,
|
||||
marginBottom: 6,
|
||||
}}
|
||||
textStyle={{ fontSize: 20, fontWeight: "normal" }}
|
||||
title={str}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
const WidgetList = ({ id }) => {
|
||||
const { colors } = useThemeColors();
|
||||
const [widgetConfig, setWidgetConfig] = useState("");
|
||||
const reload = () => {
|
||||
AS.getItem(`widgetType/${id}`)
|
||||
.then((widgetType) => {
|
||||
setWidgetConfig(widgetType);
|
||||
})
|
||||
.catch((e) => {
|
||||
setWidgetConfig("JR_shikoku_train_info");
|
||||
});
|
||||
};
|
||||
useEffect(reload, [id]);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={id}
|
||||
onPress={() => {
|
||||
//widget.widgetNameで定義されてないもう一つのウィジェットを選択する
|
||||
if (widgetConfig === "Info_Widget") {
|
||||
AS.setItem(`widgetType/${id}`, "JR_shikoku_train_info");
|
||||
} else {
|
||||
AS.setItem(`widgetType/${id}`, "Info_Widget");
|
||||
}
|
||||
reload();
|
||||
}}
|
||||
style={{ flexDirection: "row", padding: 8, borderBottomWidth: 1, borderBottomColor: colors.border }}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 20,
|
||||
alignItems: "center",
|
||||
alignContent: "center",
|
||||
textAlign: "center",
|
||||
textAlignVertical: "center",
|
||||
marginRight: 10,
|
||||
}}
|
||||
>
|
||||
{id}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 20,
|
||||
alignItems: "center",
|
||||
alignContent: "center",
|
||||
textAlign: "center",
|
||||
textAlignVertical: "center",
|
||||
}}
|
||||
>
|
||||
{widgetConfig}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
{label}
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
borderRadius: 15,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 2,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -53,22 +53,25 @@ export default function Setting(props) {
|
||||
}, []);
|
||||
const testNFC = async () => {
|
||||
console.log("Testing NFC...");
|
||||
const result = await ExpoFelicaReader.scan().then(x=>{
|
||||
console.log("NFC Scan Result:", x);
|
||||
return x;
|
||||
});
|
||||
try {
|
||||
const result = await ExpoFelicaReader.scan();
|
||||
console.log("NFC Scan Result:", result);
|
||||
|
||||
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"),
|
||||
});
|
||||
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}`);
|
||||
} catch (e) {
|
||||
console.error("NFC scan failed:", e);
|
||||
alert(`NFC読み取りに失敗しました: ${(e as Error).message}`);
|
||||
}
|
||||
|
||||
console.log("NFC Result:", result);
|
||||
alert(`IDm: ${result.idm}\n残高: ${result.balance}円\nシステムコード: ${result.systemCode}`);
|
||||
};
|
||||
const updateAndReload = () => {
|
||||
Promise.all([
|
||||
|
||||
@@ -42,7 +42,7 @@ export const ExGridSimpleViewItem: FC<{
|
||||
const { allCustomTrainData } = useAllTrainDiagram();
|
||||
const { originalStationList, stationList } = useStationList();
|
||||
const { navigate } = useNavigation();
|
||||
const { colors } = useThemeColors();
|
||||
const { colors, isDark } = useThemeColors();
|
||||
const [trainData, setTrainData] = useState<CustomTrainData>();
|
||||
useEffect(() => {
|
||||
if (allCustomTrainData) {
|
||||
@@ -55,7 +55,7 @@ export const ExGridSimpleViewItem: FC<{
|
||||
}, []);
|
||||
const { color, data } = getTrainType({
|
||||
type: trainData?.type,
|
||||
whiteMode: true,
|
||||
whiteMode: !isDark,
|
||||
});
|
||||
// 行き先(駅名)の取得
|
||||
const [destinationName] = useMemo(() => {
|
||||
|
||||
@@ -42,7 +42,7 @@ export const ExGridViewItem: FC<{
|
||||
const { allCustomTrainData } = useAllTrainDiagram();
|
||||
const { originalStationList, stationList } = useStationList();
|
||||
const { navigate } = useNavigation();
|
||||
const { colors } = useThemeColors();
|
||||
const { colors, isDark } = useThemeColors();
|
||||
const [trainData, setTrainData] = useState<CustomTrainData>();
|
||||
useEffect(() => {
|
||||
if (allCustomTrainData) {
|
||||
@@ -53,7 +53,7 @@ export const ExGridViewItem: FC<{
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
const { color, data } = getTrainType({ type: trainData?.type, whiteMode: true });
|
||||
const { color, data } = getTrainType({ type: trainData?.type, whiteMode: !isDark });
|
||||
// 列車名、種別、フォントの取得
|
||||
const [
|
||||
trainName,
|
||||
|
||||
@@ -31,7 +31,7 @@ export const ListViewItem: FC<{
|
||||
}> = ({ d, showVehicle = false, showAppSource = true, getUnyohubByTrainNumber, getElesiteByTrainNumber }) => {
|
||||
const { allCustomTrainData, getTodayOperationByTrainId } = useAllTrainDiagram();
|
||||
const { navigate } = useNavigation();
|
||||
const { colors, fixed } = useThemeColors();
|
||||
const { colors, fixed, isDark } = useThemeColors();
|
||||
const [trainData, setTrainData] = useState<CustomTrainData | undefined>();
|
||||
useEffect(() => {
|
||||
if (allCustomTrainData) {
|
||||
@@ -44,7 +44,7 @@ export const ListViewItem: FC<{
|
||||
}, []);
|
||||
const { color, data } = getTrainType({
|
||||
type: trainData?.type,
|
||||
whiteMode: true,
|
||||
whiteMode: !isDark,
|
||||
});
|
||||
// 列車名、種別、フォントの取得
|
||||
const { getStationDataFromName, originalStationList } =
|
||||
@@ -214,19 +214,19 @@ export const ListViewItem: FC<{
|
||||
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" });
|
||||
sources.push({ label: "運用", text: unitIds.join("・"), badgeColor: "#00BCD4", textColor: isDark ? "#4dd0e1" : "#006064" });
|
||||
}
|
||||
}
|
||||
const unyoText = getUnyohubByTrainNumber?.(d.trainNumber);
|
||||
if (unyoText) {
|
||||
sources.push({ label: "Hub", text: unyoText, badgeColor: "#9E9E9E", textColor: "#424242" });
|
||||
sources.push({ label: "Hub", text: unyoText, badgeColor: "#9E9E9E", textColor: isDark ? "#cccccc" : "#424242" });
|
||||
}
|
||||
const elesiteText = getElesiteByTrainNumber?.(d.trainNumber);
|
||||
if (elesiteText) {
|
||||
sources.push({ label: "えれ", text: elesiteText, badgeColor: "#4CAF50", textColor: "#1B5E20" });
|
||||
sources.push({ label: "えれ", text: elesiteText, badgeColor: "#4CAF50", textColor: isDark ? "#81c784" : "#1B5E20" });
|
||||
}
|
||||
return sources;
|
||||
}, [showVehicle, showAppSource, d.trainNumber, getTodayOperationByTrainId, getUnyohubByTrainNumber, getElesiteByTrainNumber]);
|
||||
}, [showVehicle, showAppSource, d.trainNumber, getTodayOperationByTrainId, getUnyohubByTrainNumber, getElesiteByTrainNumber, isDark]);
|
||||
|
||||
const [sourceIndex, setSourceIndex] = useState(0);
|
||||
const fadeAnim = useRef(new Animated.Value(1)).current;
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
ScrollView,
|
||||
TextInput,
|
||||
Keyboard,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
LayoutAnimation,
|
||||
@@ -27,6 +26,7 @@ import { trainTypeID } from "@/lib/CommonTypes";
|
||||
import { SearchInputSuggestBox } from "./SearchBox/SearchInputSuggestBox";
|
||||
import { ExGridSimpleView } from "./ExGridSimpleView";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { useStationLockActivity } from "@/lib/useStationLockActivity";
|
||||
|
||||
type props = {
|
||||
route: {
|
||||
@@ -64,6 +64,7 @@ export const StationDiagramView: FC<props> = ({ route }) => {
|
||||
const { goBack } = useNavigation();
|
||||
const { colors, fixed } = useThemeColors();
|
||||
const [keyBoardVisible, setKeyBoardVisible] = useState(false);
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||
const [input, setInput] = useState("");
|
||||
const [displayMode, setDisplayMode] = useState<
|
||||
"list" | "grid" | "simpleGrid"
|
||||
@@ -90,6 +91,9 @@ export const StationDiagramView: FC<props> = ({ route }) => {
|
||||
const [sourceModalVisible, setSourceModalVisible] = useState(false);
|
||||
// 有効なソースのうち表示するソース(初期=全て表示)
|
||||
const [visibleSources, setVisibleSources] = useState<{ app: boolean; hub: boolean; elesite: boolean }>({ app: true, hub: true, elesite: true });
|
||||
|
||||
// 駅ロック Live Activity (iOS only)
|
||||
const stationLock = useStationLockActivity();
|
||||
const [currentStationDiagram, setCurrentStationDiagram] = useState<hoge>([]);
|
||||
useEffect(() => {
|
||||
if (allTrainDiagram && currentStation.length > 0) {
|
||||
@@ -191,12 +195,13 @@ export const StationDiagramView: FC<props> = ({ route }) => {
|
||||
}, [currentStation, showLastStop, threw, input, selectedTypeList]);
|
||||
|
||||
useEffect(() => {
|
||||
const showSubscription = Keyboard.addListener("keyboardDidShow", () => {
|
||||
const showSubscription = Keyboard.addListener("keyboardDidShow", (e) => {
|
||||
LayoutAnimation.configureNext({
|
||||
duration: 600,
|
||||
update: { type: "spring", springDamping: 0.6 },
|
||||
});
|
||||
setKeyBoardVisible(true);
|
||||
setKeyboardHeight(e.endCoordinates.height);
|
||||
});
|
||||
const hideSubscription = Keyboard.addListener("keyboardDidHide", () => {
|
||||
LayoutAnimation.configureNext({
|
||||
@@ -204,6 +209,7 @@ export const StationDiagramView: FC<props> = ({ route }) => {
|
||||
update: { type: "spring", springDamping: 0.6 },
|
||||
});
|
||||
setKeyBoardVisible(false);
|
||||
setKeyboardHeight(0);
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -211,8 +217,35 @@ export const StationDiagramView: FC<props> = ({ route }) => {
|
||||
hideSubscription.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 時刻表データが更新されたら駅ロック Live Activity を自動更新
|
||||
useEffect(() => {
|
||||
if (stationLock.status !== "active") return;
|
||||
if (currentStationDiagram.length === 0) return;
|
||||
|
||||
const now = dayjs();
|
||||
const nextTrain = currentStationDiagram.find(
|
||||
(d) => dayjs(d.time, "HH:mm").isAfter(now)
|
||||
);
|
||||
const followingTrain = currentStationDiagram.find(
|
||||
(d) => dayjs(d.time, "HH:mm").isAfter(now) && d !== nextTrain
|
||||
);
|
||||
if (!nextTrain) return;
|
||||
|
||||
stationLock.update({
|
||||
nextTrainTime: nextTrain.time,
|
||||
nextTrainDestination: nextTrain.name,
|
||||
nextTrainPlatform: "",
|
||||
followingTrainTime: followingTrain?.time ?? "",
|
||||
followingTrainDestination: followingTrain?.name ?? "",
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentStationDiagram]);
|
||||
|
||||
return (
|
||||
<View style={{ height: "100%", backgroundColor: fixed.primary }}>
|
||||
<View
|
||||
style={{ flex: 1, backgroundColor: fixed.primary, paddingBottom: Platform.OS === "ios" ? keyboardHeight : 0 }}
|
||||
>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", paddingVertical: 10, paddingHorizontal: 10 }}>
|
||||
<Text
|
||||
style={{
|
||||
@@ -238,6 +271,45 @@ export const StationDiagramView: FC<props> = ({ route }) => {
|
||||
>
|
||||
<Text style={{ fontSize: 18 }}>🚃</Text>
|
||||
</TouchableOpacity>
|
||||
{Platform.OS === "ios" && stationLock.available && (
|
||||
<TouchableOpacity
|
||||
onPress={async () => {
|
||||
if (stationLock.status === "active") {
|
||||
await stationLock.end();
|
||||
return;
|
||||
}
|
||||
const now = dayjs();
|
||||
const nextTrain = currentStationDiagram.find(
|
||||
(d) => dayjs(d.time, "HH:mm").isAfter(now)
|
||||
);
|
||||
const followingTrain = currentStationDiagram.find(
|
||||
(d) => dayjs(d.time, "HH:mm").isAfter(now) && d !== nextTrain
|
||||
);
|
||||
await stationLock.start({
|
||||
stationName: currentStation[0].Station_JP,
|
||||
nextTrainTime: nextTrain?.time ?? "",
|
||||
nextTrainDestination: nextTrain?.name ?? "",
|
||||
nextTrainPlatform: "",
|
||||
followingTrainTime: followingTrain?.time ?? "",
|
||||
followingTrainDestination: followingTrain?.name ?? "",
|
||||
});
|
||||
}}
|
||||
disabled={stationLock.status === "loading"}
|
||||
style={{
|
||||
backgroundColor: stationLock.status === "active"
|
||||
? "rgba(220, 50, 50, 0.2)"
|
||||
: "rgba(255,255,255,0.3)",
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
marginLeft: 4,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 16 }}>
|
||||
{stationLock.status === "active" ? "🔴" : "🟢"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* ソース選択モーダル */}
|
||||
@@ -297,11 +369,7 @@ export const StationDiagramView: FC<props> = ({ route }) => {
|
||||
>
|
||||
お気に入り登録した駅のうち、位置情報システムで移動可能な駅が表示されています。タップすることで位置情報システムの当該の駅に移動します。
|
||||
</Text> */}
|
||||
<KeyboardAvoidingView
|
||||
behavior="padding"
|
||||
keyboardVerticalOffset={80}
|
||||
enabled={Platform.OS === "ios"}
|
||||
>
|
||||
<View>
|
||||
{!keyBoardVisible ? (
|
||||
<ScrollView
|
||||
horizontal
|
||||
@@ -533,7 +601,7 @@ export const StationDiagramView: FC<props> = ({ route }) => {
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
{keyBoardVisible || (
|
||||
<BigButton onPress={() => goBack()} string="閉じる" />
|
||||
)}
|
||||
|
||||
@@ -2,14 +2,13 @@ import React, { FC, useEffect } from "react";
|
||||
import { Platform, Text } from "react-native";
|
||||
import { useFavoriteStation } from "../../stateBox/useFavoriteStation";
|
||||
import { StationProps } from "@/lib/CommonTypes";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { lightColors } from "@/lib/theme";
|
||||
type Props = {
|
||||
currentStation: StationProps[];
|
||||
isMatsuyama: boolean;
|
||||
};
|
||||
export const AddressText: FC<Props> = (props) => {
|
||||
const { currentStation, isMatsuyama } = props;
|
||||
const { colors } = useThemeColors();
|
||||
const {lodAddMigration} = useFavoriteStation();
|
||||
const [stationAddress, setStationAddress] = React.useState("");
|
||||
useEffect(() => {
|
||||
@@ -31,7 +30,7 @@ export const AddressText: FC<Props> = (props) => {
|
||||
<Text
|
||||
style={{
|
||||
fontSize: parseInt("10%"),
|
||||
color: isMatsuyama ? "white" : colors.textStationName,
|
||||
color: isMatsuyama ? "white" : lightColors.textStationName,
|
||||
position: "absolute",
|
||||
bottom: isMatsuyama ? Platform.OS === "ios"?"25.5%":"26.5%" : "0%",
|
||||
textAlign: "center",
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "react-native";
|
||||
import { StationName } from "./StationName";
|
||||
import lineColorList from "../../assets/originData/lineColorList";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { useThemeColors, lightColors } from "@/lib/theme";
|
||||
export const NextPreStationLine = ({ nexStation, preStation, isMatsuyama }) => {
|
||||
const 下枠フレーム: ViewStyle = {
|
||||
flex: 1,
|
||||
@@ -76,7 +76,6 @@ const BottomSideArrow: FC<FCimport> = ({ isMatsuyama, children }) => {
|
||||
|
||||
const BottomStationNumberView: FC<FCimport> = ({ isMatsuyama, children }) => {
|
||||
const { width } = useWindowDimensions();
|
||||
const { colors } = useThemeColors();
|
||||
const lineID = children.slice(0, 1);
|
||||
const lineName = children.slice(1);
|
||||
const 下枠駅ナンバー: ViewStyle = {
|
||||
@@ -85,7 +84,7 @@ const BottomStationNumberView: FC<FCimport> = ({ isMatsuyama, children }) => {
|
||||
width: width * 0.08,
|
||||
height: width * 0.08,
|
||||
margin: width * 0.01,
|
||||
backgroundColor: colors.surface,
|
||||
backgroundColor: lightColors.surface,
|
||||
borderWidth: parseInt("3%"),
|
||||
borderRadius: parseInt("100%"),
|
||||
};
|
||||
@@ -95,7 +94,7 @@ const BottomStationNumberView: FC<FCimport> = ({ isMatsuyama, children }) => {
|
||||
width: width * 0.07,
|
||||
height: width * 0.07,
|
||||
margin: width * 0.02,
|
||||
backgroundColor: colors.surface,
|
||||
backgroundColor: lightColors.surface,
|
||||
borderWidth: parseInt("3%"),
|
||||
borderRadius: parseInt("100%"),
|
||||
};
|
||||
@@ -110,7 +109,7 @@ const BottomStationNumberView: FC<FCimport> = ({ isMatsuyama, children }) => {
|
||||
<Text
|
||||
style={{
|
||||
fontSize: parseInt(Platform.OS === "ios" ? "8%" : "10%"),
|
||||
color: colors.text,
|
||||
color: lightColors.text,
|
||||
textAlign: "center",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
|
||||
@@ -18,14 +18,14 @@ import { NextPreStationLine } from "./NextPreStationLine";
|
||||
import { LottieDelayView } from "./LottieDelayView";
|
||||
import { AddressText } from "./AddressText";
|
||||
import { useStationList } from "../../stateBox/useStationList";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { useThemeColors, lightColors } from "@/lib/theme";
|
||||
|
||||
export default function Sign(props) {
|
||||
const { oP, oLP, isCurrentStation = false, stationID } = props;
|
||||
|
||||
const { width, height } = useWindowDimensions();
|
||||
const { getStationDataFromId } = useStationList();
|
||||
const { colors, fixed } = useThemeColors();
|
||||
const { fixed } = useThemeColors();
|
||||
if (!stationID) {
|
||||
return <></>;
|
||||
}
|
||||
@@ -133,7 +133,7 @@ export default function Sign(props) {
|
||||
height: ((width * 0.8) / 20) * 9,
|
||||
borderColor: fixed.primary,
|
||||
borderWidth: 1,
|
||||
backgroundColor: colors.surface,
|
||||
backgroundColor: lightColors.surface,
|
||||
},
|
||||
外枠B: {
|
||||
width: width * 0.8,
|
||||
@@ -162,7 +162,7 @@ export default function Sign(props) {
|
||||
left: "2%",
|
||||
fontWeight: "bold",
|
||||
fontSize: parseInt("25%"),
|
||||
color: colors.textAccent,
|
||||
color: lightColors.textAccent,
|
||||
},
|
||||
下帯内容: {
|
||||
position: "absolute",
|
||||
@@ -195,7 +195,7 @@ export default function Sign(props) {
|
||||
style={{
|
||||
width: width * 0.8,
|
||||
height: ((width * 0.8) / 20) * 9,
|
||||
backgroundColor: colors.surface,
|
||||
backgroundColor: lightColors.surface,
|
||||
}}
|
||||
source={require("../../assets/StationSign.json")}
|
||||
/>
|
||||
@@ -209,7 +209,7 @@ export default function Sign(props) {
|
||||
<MaterialCommunityIcons
|
||||
name="crosshairs-gps"
|
||||
style={{ padding: 5 }}
|
||||
color={colors.iconAccent}
|
||||
color={lightColors.iconAccent}
|
||||
size={30}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { CSSProperties } from "react";
|
||||
import { Platform, Text, TextStyle, View, ViewStyle } from "react-native";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { lightColors } from "@/lib/theme";
|
||||
|
||||
export const StationNameArea = (props) => {
|
||||
const { currentStation, isMatsuyama } = props;
|
||||
const { colors } = useThemeColors();
|
||||
|
||||
const stationNameAreaOverWrap: ViewStyle = {
|
||||
position: "absolute",
|
||||
@@ -21,12 +20,12 @@ export const StationNameArea = (props) => {
|
||||
fontWeight: "bold",
|
||||
fontSize: parseInt(currentStation[0].Station_JP.length < 6 ? "40%" : "25%"),
|
||||
|
||||
color: isMatsuyama ? "white" : colors.textStationName,
|
||||
color: isMatsuyama ? "white" : lightColors.textStationName,
|
||||
};
|
||||
const Station_EN: TextStyle = {
|
||||
fontWeight: "bold",
|
||||
fontSize: parseInt("15%"),
|
||||
color: isMatsuyama ? "white" : colors.textStationName,
|
||||
color: isMatsuyama ? "white" : lightColors.textStationName,
|
||||
};
|
||||
return (
|
||||
<View style={stationNameAreaOverWrap}>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Text, View } from "react-native";
|
||||
import { useWindowDimensions } from "react-native";
|
||||
import lineColorList from "@/assets/originData/lineColorList";
|
||||
import { StationProps } from "@/lib/CommonTypes";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { lightColors } from "@/lib/theme";
|
||||
|
||||
type props = {
|
||||
currentStation: StationProps[];
|
||||
@@ -13,7 +13,6 @@ type props = {
|
||||
export const StationNumberMaker: FC<props> = (props) => {
|
||||
const { currentStation, useEach = false, singleSize } = props;
|
||||
const { width } = useWindowDimensions();
|
||||
const { colors } = useThemeColors();
|
||||
const getTop = (array: number[], index: number) => {
|
||||
if (array.length == 1) return 20;
|
||||
else if (index == 0) return 5;
|
||||
@@ -31,7 +30,7 @@ export const StationNumberMaker: FC<props> = (props) => {
|
||||
alignContent: "center",
|
||||
alignItems: "center",
|
||||
borderColor: lineColorList[lineID],
|
||||
backgroundColor: colors.surface,
|
||||
backgroundColor: lightColors.surface,
|
||||
...(useEach ? {
|
||||
width: singleSize,
|
||||
height: singleSize,
|
||||
@@ -55,7 +54,7 @@ export const StationNumberMaker: FC<props> = (props) => {
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
textAlign: "center",
|
||||
color: colors.text,
|
||||
color: lightColors.text,
|
||||
fontWeight: "bold",
|
||||
...(useEach ? {
|
||||
|
||||
|
||||
@@ -13,16 +13,16 @@
|
||||
"channel": "preview"
|
||||
},
|
||||
"mapsbuild": {
|
||||
"releaseChannel": "mapsbuild"
|
||||
"channel": "mapsbuild"
|
||||
},
|
||||
"production": {
|
||||
"releaseChannel": "aliexpress"
|
||||
"channel": "aliexpress"
|
||||
},
|
||||
"beta4.5": {
|
||||
"releaseChannel": "base"
|
||||
"channel": "base"
|
||||
},
|
||||
"production4.5": {
|
||||
"releaseChannel": "buyma"
|
||||
"channel": "buyma"
|
||||
},
|
||||
"beta4.6": {
|
||||
"channel": "catch"
|
||||
@@ -41,6 +41,9 @@
|
||||
},
|
||||
"production6.0": {
|
||||
"channel": "ebay"
|
||||
},
|
||||
"beta7.0": {
|
||||
"channel": "fdroid"
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ export const initIcon = (
|
||||
return ({ focused, color, size }) => (
|
||||
<>
|
||||
{!!tabBarBadge && <Badge tabBarBadge={tabBarBadge} isInfo={isInfo} />}
|
||||
<IconComponent name={name} size={30} color={focused ? "#0099CC" : "black"} />
|
||||
<IconComponent name={name} size={30} color={color} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+8
-3
@@ -10,11 +10,16 @@ type State = "RUNNING" | "STOPPED";
|
||||
|
||||
type Fn = () => void;
|
||||
|
||||
export const useInterval = (fn: Fn, interval: number, autostart = true) => {
|
||||
export const useInterval = (fn: Fn, interval: number, autostart = true, keepAliveInBackground = false) => {
|
||||
const onUpdateRef = useRef<Fn>();
|
||||
const [state, setState] = useState("RUNNING");
|
||||
// ユーザー操作によるSTOP(AppStateによる一時停止と区別する)
|
||||
const userStoppedRef = useRef(!autostart);
|
||||
const keepAliveRef = useRef(keepAliveInBackground);
|
||||
|
||||
useEffect(() => {
|
||||
keepAliveRef.current = keepAliveInBackground;
|
||||
}, [keepAliveInBackground]);
|
||||
|
||||
const start = () => {
|
||||
userStoppedRef.current = false;
|
||||
@@ -48,7 +53,7 @@ export const useInterval = (fn: Fn, interval: number, autostart = true) => {
|
||||
setState("RUNNING");
|
||||
}
|
||||
} else if (nextAppState === "background" || nextAppState === "inactive") {
|
||||
if (!userStoppedRef.current) {
|
||||
if (!userStoppedRef.current && !keepAliveRef.current) {
|
||||
// バックグラウンド中はインターバルを停止してムダなfetchエラーを防ぐ
|
||||
setState("STOPPED");
|
||||
}
|
||||
@@ -73,7 +78,7 @@ export const useInterval = (fn: Fn, interval: number, autostart = true) => {
|
||||
if (timerId) clearInterval(timerId);
|
||||
};
|
||||
}, [interval, state]);
|
||||
return [state, { start, stop }];
|
||||
return [state, { start, stop }] as const;
|
||||
};
|
||||
|
||||
export default useInterval;
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
endStationLockActivity,
|
||||
getActiveStationLockActivities,
|
||||
isAvailable,
|
||||
startStationLockActivity,
|
||||
StationLockParams,
|
||||
StationLockState,
|
||||
updateStationLockActivity,
|
||||
} from "expo-live-activity";
|
||||
|
||||
type Status = "idle" | "active" | "loading" | "error";
|
||||
|
||||
type UseStationLockActivityResult = {
|
||||
/** Live Activity が利用可能なデバイス・OS かどうか */
|
||||
available: boolean;
|
||||
/** 現在の状態 */
|
||||
status: Status;
|
||||
/** エラーメッセージ (status === "error" のとき) */
|
||||
error: string | null;
|
||||
/** 現在のアクティビティ ID (active のときのみ非 null) */
|
||||
activityId: string | null;
|
||||
/** 駅ロック Live Activity を開始する */
|
||||
start: (params: StationLockParams) => Promise<void>;
|
||||
/** Live Activity の状態を更新する */
|
||||
update: (state: StationLockState) => Promise<void>;
|
||||
/** Live Activity を終了する */
|
||||
end: () => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 駅ロック Live Activity を管理するフック。
|
||||
* アンマウント時に自動で Live Activity を終了する。
|
||||
*/
|
||||
export function useStationLockActivity(): UseStationLockActivityResult {
|
||||
const [available] = useState(() => isAvailable());
|
||||
const [status, setStatus] = useState<Status>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activityId, setActivityId] = useState<string | null>(() => {
|
||||
// アプリ再起動後など、既存のアクティビティを復元する
|
||||
const ids = getActiveStationLockActivities();
|
||||
return ids.length > 0 ? ids[0] : null;
|
||||
});
|
||||
|
||||
// マウント時に既存アクティビティがあれば active に設定
|
||||
useEffect(() => {
|
||||
if (activityId) setStatus("active");
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// アンマウント時に自動終了
|
||||
const activityIdRef = useRef(activityId);
|
||||
useEffect(() => {
|
||||
activityIdRef.current = activityId;
|
||||
}, [activityId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (activityIdRef.current) {
|
||||
endStationLockActivity(activityIdRef.current).catch(() => {});
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const start = useCallback(async (params: StationLockParams) => {
|
||||
if (!available) return;
|
||||
setStatus("loading");
|
||||
setError(null);
|
||||
try {
|
||||
const id = await startStationLockActivity(params);
|
||||
setActivityId(id);
|
||||
setStatus("active");
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setError(msg);
|
||||
setStatus("error");
|
||||
}
|
||||
}, [available]);
|
||||
|
||||
const update = useCallback(async (state: StationLockState) => {
|
||||
if (!activityIdRef.current) return;
|
||||
try {
|
||||
await updateStationLockActivity(activityIdRef.current, state);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setError(msg);
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const end = useCallback(async () => {
|
||||
if (!activityIdRef.current) return;
|
||||
try {
|
||||
await endStationLockActivity(activityIdRef.current);
|
||||
setActivityId(null);
|
||||
setStatus("idle");
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setError(msg);
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { available, status, error, activityId, start, update, end };
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
endTrainFollowActivity,
|
||||
getActiveTrainFollowActivities,
|
||||
isAvailable,
|
||||
startTrainFollowActivity,
|
||||
TrainFollowParams,
|
||||
TrainFollowState,
|
||||
updateTrainFollowActivity,
|
||||
} from "expo-live-activity";
|
||||
|
||||
type Status = "idle" | "active" | "loading" | "error";
|
||||
|
||||
type UseTrainFollowActivityResult = {
|
||||
/** Live Activity が利用可能なデバイス・OS かどうか */
|
||||
available: boolean;
|
||||
/** 現在の状態 */
|
||||
status: Status;
|
||||
/** エラーメッセージ (status === "error" のとき) */
|
||||
error: string | null;
|
||||
/** 現在のアクティビティ ID (active のときのみ非 null) */
|
||||
activityId: string | null;
|
||||
/** 列車追従 Live Activity を開始する */
|
||||
start: (params: TrainFollowParams) => Promise<void>;
|
||||
/** Live Activity の状態を更新する */
|
||||
update: (state: TrainFollowState) => Promise<void>;
|
||||
/** Live Activity を終了する */
|
||||
end: () => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 列車追従 Live Activity を管理するフック。
|
||||
* アンマウント時に自動で Live Activity を終了する。
|
||||
*/
|
||||
export function useTrainFollowActivity(): UseTrainFollowActivityResult {
|
||||
const [available] = useState(() => isAvailable());
|
||||
const [status, setStatus] = useState<Status>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activityId, setActivityId] = useState<string | null>(() => {
|
||||
// アプリ再起動後など、既存のアクティビティを復元する
|
||||
const ids = getActiveTrainFollowActivities();
|
||||
return ids.length > 0 ? ids[0] : null;
|
||||
});
|
||||
|
||||
// マウント時に既存アクティビティがあれば active に設定
|
||||
useEffect(() => {
|
||||
if (activityId) setStatus("active");
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// アンマウント時に自動終了
|
||||
const activityIdRef = useRef(activityId);
|
||||
useEffect(() => {
|
||||
activityIdRef.current = activityId;
|
||||
}, [activityId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (activityIdRef.current) {
|
||||
endTrainFollowActivity(activityIdRef.current).catch(() => {});
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const start = useCallback(async (params: TrainFollowParams) => {
|
||||
if (!available) return;
|
||||
setStatus("loading");
|
||||
setError(null);
|
||||
try {
|
||||
const id = await startTrainFollowActivity(params);
|
||||
setActivityId(id);
|
||||
setStatus("active");
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setError(msg);
|
||||
setStatus("error");
|
||||
}
|
||||
}, [available]);
|
||||
|
||||
const update = useCallback(async (state: TrainFollowState) => {
|
||||
if (!activityIdRef.current) return;
|
||||
try {
|
||||
await updateTrainFollowActivity(activityIdRef.current, state);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setError(msg);
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const end = useCallback(async () => {
|
||||
if (!activityIdRef.current) return;
|
||||
try {
|
||||
await endTrainFollowActivity(activityIdRef.current);
|
||||
setActivityId(null);
|
||||
setStatus("idle");
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setError(msg);
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { available, status, error, activityId, start, update, end };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useRef, useState, useEffect, useCallback, useMemo, FC } from "react";
|
||||
import { Platform, View, ScrollView, LayoutAnimation, Text, InteractionManager } from "react-native";
|
||||
import Constants from "expo-constants";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import {
|
||||
configureReanimatedLogger,
|
||||
ReanimatedLogLevel,
|
||||
@@ -44,6 +45,7 @@ export const Menu: FC<props> = (props) => {
|
||||
const { scrollRef, mapHeight, MapFullHeight, mapMode, setMapMode } = props;
|
||||
const { navigate } = useNavigation();
|
||||
const { colors } = useThemeColors();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { favoriteStation } = useFavoriteStation();
|
||||
const { originalStationList, getStationDataFromNameBase } = useStationList();
|
||||
const [stationSource, _setStationSource] = useState<StationSource>({ type: "position" });
|
||||
@@ -276,7 +278,7 @@ export const Menu: FC<props> = (props) => {
|
||||
style={{
|
||||
height: "100%",
|
||||
backgroundColor: colors.background,
|
||||
paddingTop: Platform.OS == "ios" ? Constants.statusBarHeight : 0,
|
||||
paddingTop: Platform.OS === "web" ? 0 : insets.top,
|
||||
}}
|
||||
>
|
||||
<StatusbarDetect />
|
||||
|
||||
@@ -7,9 +7,4 @@ config.transformer = {
|
||||
experimentalImportSupport: true,
|
||||
};
|
||||
|
||||
config.resolver = {
|
||||
...config.resolver,
|
||||
experimentalImportSupport: true,
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
|
||||
+7
@@ -14,6 +14,13 @@ class ExpoFelicaReaderModule : Module() {
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("ExpoFelicaReader")
|
||||
|
||||
/**
|
||||
* このデバイスが NFC (FeliCa) に対応しているかを返す。
|
||||
*/
|
||||
Function("isAvailable") {
|
||||
nfcAdapter != null
|
||||
}
|
||||
|
||||
/**
|
||||
* FeliCa カードをスキャンして残高等を読み取る。
|
||||
* 戻り値: { idm: string, balance: number, systemCode: string }
|
||||
|
||||
@@ -16,6 +16,7 @@ Pod::Spec.new do |s|
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
s.frameworks = 'CoreNFC'
|
||||
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
|
||||
@@ -1,200 +1,236 @@
|
||||
import ExpoModulesCore
|
||||
import CoreNFC
|
||||
import WidgetKit
|
||||
|
||||
struct HistoryEntryRecord: Record {
|
||||
@Field var terminalType: Int = 0
|
||||
@Field var processType: Int = 0
|
||||
@Field var processNumber: Int = 0
|
||||
@Field var year: Int = 0
|
||||
@Field var month: Int = 0
|
||||
@Field var day: Int = 0
|
||||
@Field var balance: Int = 0
|
||||
@Field var inLineCode: Int = 0
|
||||
@Field var inStationCode: Int = 0
|
||||
@Field var outLineCode: Int = 0
|
||||
@Field var outStationCode: Int = 0
|
||||
@Field var companyCode: Int = 0
|
||||
@Field var regionCode: Int = 0
|
||||
}
|
||||
|
||||
struct ScanResultRecord: Record {
|
||||
@Field var idm: String = ""
|
||||
@Field var balance: Int = 0
|
||||
@Field var systemCode: String = ""
|
||||
@Field var history: [HistoryEntryRecord] = []
|
||||
}
|
||||
|
||||
public class ExpoFelicaReaderModule: Module {
|
||||
private var readerSession: FelicaReaderSession?
|
||||
// Strong reference to keep delegate alive during NFC session
|
||||
private var activeDelegate: FelicaSessionDelegate?
|
||||
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("ExpoFelicaReader")
|
||||
|
||||
/**
|
||||
* FeliCa カードをスキャンして残高等を読み取る。
|
||||
* 戻り値: { idm: String, balance: Int, systemCode: String }
|
||||
* - balance が -1 の場合は読み取り失敗
|
||||
*/
|
||||
Function("ping") { () -> String in
|
||||
return "pong"
|
||||
}
|
||||
|
||||
/// このデバイスが NFC (FeliCa) に対応しているかを返す
|
||||
Function("isAvailable") { () -> Bool in
|
||||
return NFCTagReaderSession.readingAvailable
|
||||
}
|
||||
|
||||
/// App Group UserDefaults に残高データを保存する(iOS ウィジェット連携用)
|
||||
Function("saveWidgetData") { (key: String, jsonString: String) in
|
||||
let suiteName = "group.jrshikokuinfo.xprocess.hrkn"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else { return }
|
||||
defaults.set(jsonString.data(using: .utf8), forKey: key)
|
||||
if #available(iOS 14.0, *) {
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
|
||||
AsyncFunction("scan") { (promise: Promise) in
|
||||
guard NFCTagReaderSession.readingAvailable else {
|
||||
promise.reject("NFC_NOT_AVAILABLE", "このデバイスは NFC に対応していません")
|
||||
let available = NFCTagReaderSession.readingAvailable
|
||||
guard available else {
|
||||
promise.reject("ERR_NFC_NOT_AVAILABLE", "NFC is not available on this device (readingAvailable=\(available))")
|
||||
return
|
||||
}
|
||||
self.readerSession = FelicaReaderSession(promise: promise)
|
||||
self.readerSession?.start()
|
||||
|
||||
let delegate = FelicaSessionDelegate(promise: promise, onComplete: { [weak self] in
|
||||
self?.activeDelegate = nil
|
||||
})
|
||||
guard let session = NFCTagReaderSession(
|
||||
pollingOption: .iso18092,
|
||||
delegate: delegate,
|
||||
queue: DispatchQueue.main
|
||||
) else {
|
||||
promise.reject("ERR_NFC_SESSION", "Failed to create NFC session (pollingOption=iso18092, readingAvailable=\(available))")
|
||||
return
|
||||
}
|
||||
delegate.session = session
|
||||
// Keep strong reference so delegate survives until NFC session ends
|
||||
self.activeDelegate = delegate
|
||||
session.alertMessage = "交通系ICカードをiPhoneの上部に当ててください"
|
||||
session.begin()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - FeliCa NFC セッション管理
|
||||
// MARK: - NFCTagReaderSession Delegate
|
||||
|
||||
class FelicaReaderSession: NSObject, NFCTagReaderSessionDelegate {
|
||||
private let promise: Promise
|
||||
private var session: NFCTagReaderSession?
|
||||
/// resolve/reject が一度だけ呼ばれるよう保護するフラグ
|
||||
private var isCompleted = false
|
||||
private class FelicaSessionDelegate: NSObject, NFCTagReaderSessionDelegate {
|
||||
let promise: Promise
|
||||
var session: NFCTagReaderSession?
|
||||
let onComplete: () -> Void
|
||||
|
||||
init(promise: Promise) {
|
||||
init(promise: Promise, onComplete: @escaping () -> Void) {
|
||||
self.promise = promise
|
||||
self.onComplete = onComplete
|
||||
}
|
||||
|
||||
func start() {
|
||||
session = NFCTagReaderSession(pollingOption: [.iso18092], delegate: self, queue: nil)
|
||||
session?.alertMessage = "交通系 IC カード(Suica / ICOCA など)をかざしてください"
|
||||
session?.begin()
|
||||
func tagReaderSessionDidBecomeActive(_ session: NFCTagReaderSession) {
|
||||
// NFC reader UI is now visible
|
||||
}
|
||||
|
||||
// セッションがアクティブになったとき
|
||||
func tagReaderSessionDidBecomeActive(_ session: NFCTagReaderSession) {}
|
||||
|
||||
// エラー / セッション終了
|
||||
func tagReaderSession(_ session: NFCTagReaderSession, didInvalidateWithError error: Error) {
|
||||
// invalidate() 正常終了後もこのデリゲートが呼ばれるため、isCompleted で二重 reject を防ぐ
|
||||
guard !isCompleted else { return }
|
||||
guard let readerError = error as? NFCReaderError else { return }
|
||||
if readerError.code != .readerSessionInvalidationErrorFirstNDEFTagRead &&
|
||||
readerError.code != .readerSessionInvalidationErrorUserCanceled {
|
||||
isCompleted = true
|
||||
promise.reject("NFC_SESSION_ERROR", error.localizedDescription)
|
||||
let nsError = error as NSError
|
||||
// User cancelled (200) or session timeout (201) — don't reject as error
|
||||
if nsError.domain == "NFCError" && (nsError.code == 200 || nsError.code == 201) {
|
||||
promise.reject("ERR_NFC_CANCELLED", "NFC scan was cancelled")
|
||||
} else {
|
||||
let detail = "domain=\(nsError.domain) code=\(nsError.code) desc=\(error.localizedDescription) userInfo=\(nsError.userInfo)"
|
||||
promise.reject("ERR_NFC_SESSION", detail)
|
||||
}
|
||||
self.session = nil
|
||||
self.onComplete()
|
||||
}
|
||||
|
||||
// タグ検出
|
||||
func tagReaderSession(_ session: NFCTagReaderSession, didDetect tags: [NFCTag]) {
|
||||
guard let first = tags.first, case .feliCa(let felicaTag) = first else {
|
||||
session.invalidate(errorMessage: "FeliCa カードではありません")
|
||||
guard let tag = tags.first, case .feliCa(let feliCaTag) = tag else {
|
||||
session.invalidate(errorMessage: "FeliCaカードが見つかりませんでした")
|
||||
return
|
||||
}
|
||||
|
||||
session.connect(to: first) { [weak self] error in
|
||||
session.connect(to: tag) { [weak self] error in
|
||||
guard let self = self else { return }
|
||||
if let error = error {
|
||||
session.invalidate(errorMessage: "接続エラー: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
self.readFeliCaCard(feliCaTag, session: session)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Read card data
|
||||
|
||||
private func readFeliCaCard(_ tag: NFCFeliCaTag, session: NFCTagReaderSession) {
|
||||
let idm = tag.currentIDm.map { String(format: "%02x", $0) }.joined()
|
||||
let systemCode = tag.currentSystemCode.map { String(format: "%02x", $0) }.joined()
|
||||
|
||||
// Service Code 0x090F (little-endian → 0x0F, 0x09) — same as Android
|
||||
let serviceCode = Data([0x0f, 0x09])
|
||||
|
||||
// Read balance from block 0 of service 0x090F (same service as history)
|
||||
tag.readWithoutEncryption(
|
||||
serviceCodeList: [serviceCode],
|
||||
blockList: [Data([0x80, 0x00])]
|
||||
) { [weak self] statusFlag1, statusFlag2, blockData, error in
|
||||
guard let self = self else { return }
|
||||
|
||||
if let error = error {
|
||||
self.isCompleted = true
|
||||
session.invalidate(errorMessage: "接続に失敗しました")
|
||||
self.promise.reject("CONNECT_ERROR", error.localizedDescription)
|
||||
return
|
||||
var balance = -1
|
||||
if error == nil && statusFlag1 == 0 && statusFlag2 == 0,
|
||||
let data = blockData.first, data.count >= 12 {
|
||||
balance = (Int(data[11]) & 0xFF) << 8 | (Int(data[10]) & 0xFF)
|
||||
}
|
||||
|
||||
let idm = felicaTag.currentIDm
|
||||
.map { String(format: "%02x", $0) }
|
||||
.joined()
|
||||
|
||||
let systemCode = felicaTag.currentSystemCode
|
||||
.map { String(format: "%02x", $0) }
|
||||
.joined()
|
||||
|
||||
// サービスコード 0x090F(交通系 IC カード残高)リトルエンディアン
|
||||
let balanceServiceCode = Data([0x0F, 0x09])
|
||||
|
||||
felicaTag.readWithoutEncryption(
|
||||
serviceCodeList: [balanceServiceCode],
|
||||
blockList: [Data([0x80, 0x00])]
|
||||
) { statusFlag1, statusFlag2, dataList, error in
|
||||
var balance = -1
|
||||
if error == nil, statusFlag1 == 0x00, statusFlag2 == 0x00,
|
||||
let block = dataList.first, block.count >= 12 {
|
||||
// バイト 10-11: 残高(リトルエンディアン 16 bit, 単位: 円)
|
||||
balance = Int(block[10]) | (Int(block[11]) << 8)
|
||||
}
|
||||
|
||||
// 利用履歴を 1 ブロックずつ再帰的に読み取る
|
||||
self.readHistory(felicaTag: felicaTag, blockNum: 0, accumulated: []) { history in
|
||||
self.isCompleted = true
|
||||
session.alertMessage = "読み取りが完了しました"
|
||||
session.invalidate()
|
||||
|
||||
self.promise.resolve([
|
||||
"idm": idm,
|
||||
"balance": balance,
|
||||
"systemCode": systemCode,
|
||||
"history": history
|
||||
])
|
||||
}
|
||||
}
|
||||
// Read history blocks one at a time (bulk read not supported by most transit cards)
|
||||
self.readHistoryBlocks(tag: tag, session: session, serviceCode: serviceCode,
|
||||
idm: idm, systemCode: systemCode, balance: balance,
|
||||
blockNum: 0, entries: [])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 利用履歴読み取り
|
||||
|
||||
/// 1 ブロックずつ再帰的に読み取り、最大 20 件の履歴を返す。
|
||||
/// 交通系 IC カードは複数ブロック一括読み取りに対応していない場合が多いため 1 件ずつ読む。
|
||||
private func readHistory(
|
||||
felicaTag: NFCFeliCaTag,
|
||||
blockNum: Int,
|
||||
accumulated: [[String: Any]],
|
||||
completion: @escaping ([[String: Any]]) -> Void
|
||||
) {
|
||||
/// Read history blocks one by one (matching Android behavior)
|
||||
private func readHistoryBlocks(tag: NFCFeliCaTag, session: NFCTagReaderSession,
|
||||
serviceCode: Data, idm: String, systemCode: String,
|
||||
balance: Int, blockNum: Int, entries: [HistoryEntryRecord]) {
|
||||
guard blockNum < 20 else {
|
||||
completion(accumulated)
|
||||
finishScan(session: session, idm: idm, systemCode: systemCode, balance: balance, entries: entries)
|
||||
return
|
||||
}
|
||||
|
||||
let historyServiceCode = Data([0x0F, 0x09])
|
||||
|
||||
felicaTag.readWithoutEncryption(
|
||||
serviceCodeList: [historyServiceCode],
|
||||
tag.readWithoutEncryption(
|
||||
serviceCodeList: [serviceCode],
|
||||
blockList: [Data([0x80, UInt8(blockNum)])]
|
||||
) { statusFlag1, statusFlag2, dataList, error in
|
||||
guard error == nil, statusFlag1 == 0x00, statusFlag2 == 0x00,
|
||||
let block = dataList.first, let entry = self.parseHistoryBlock(block) else {
|
||||
// エラーまたは空ブロック → これ以上読めないので終了
|
||||
completion(accumulated)
|
||||
) { [weak self] statusFlag1, statusFlag2, blockData, error in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Stop reading if error or no data (same as Android: break on exception)
|
||||
guard error == nil, statusFlag1 == 0, statusFlag2 == 0,
|
||||
let data = blockData.first, data.count >= 16 else {
|
||||
self.finishScan(session: session, idm: idm, systemCode: systemCode, balance: balance, entries: entries)
|
||||
return
|
||||
}
|
||||
self.readHistory(
|
||||
felicaTag: felicaTag,
|
||||
blockNum: blockNum + 1,
|
||||
accumulated: accumulated + [entry],
|
||||
completion: completion
|
||||
)
|
||||
|
||||
// Skip empty records
|
||||
if data.allSatisfy({ $0 == 0 }) {
|
||||
self.finishScan(session: session, idm: idm, systemCode: systemCode, balance: balance, entries: entries)
|
||||
return
|
||||
}
|
||||
|
||||
var updatedEntries = entries
|
||||
updatedEntries.append(self.parseHistoryBlock(data))
|
||||
|
||||
// Read next block
|
||||
self.readHistoryBlocks(tag: tag, session: session, serviceCode: serviceCode,
|
||||
idm: idm, systemCode: systemCode, balance: balance,
|
||||
blockNum: blockNum + 1, entries: updatedEntries)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 利用履歴ブロック(16 バイト)を Dictionary に変換する。
|
||||
* 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 }
|
||||
/// Parse a 16-byte history block (matching Android's parseHistoryBlock exactly)
|
||||
private func parseHistoryBlock(_ data: Data) -> HistoryEntryRecord {
|
||||
let entry = HistoryEntryRecord()
|
||||
entry.terminalType = Int(data[0]) & 0xFF
|
||||
entry.processType = Int(data[1]) & 0xFF
|
||||
// processNumber: big-endian (bytes 2-3) — matches Android
|
||||
entry.processNumber = ((Int(data[2]) & 0xFF) << 8) | (Int(data[3]) & 0xFF)
|
||||
|
||||
let terminalType = Int(block[0])
|
||||
let processType = Int(block[1])
|
||||
let processNumber = (Int(block[2]) << 8) | Int(block[3])
|
||||
// Date: bytes 4-5 — YYYYYYYM MMMDDDDD
|
||||
let dateWord = ((Int(data[4]) & 0xFF) << 8) | (Int(data[5]) & 0xFF)
|
||||
entry.year = 2000 + ((dateWord >> 9) & 0x7F)
|
||||
entry.month = (dateWord >> 5) & 0x0F
|
||||
entry.day = dateWord & 0x1F
|
||||
|
||||
let dateWord = (Int(block[4]) << 8) | Int(block[5])
|
||||
let year = 2000 + ((dateWord >> 9) & 0x7F)
|
||||
let month = (dateWord >> 5) & 0x0F
|
||||
let day = dateWord & 0x1F
|
||||
entry.inLineCode = Int(data[6]) & 0xFF
|
||||
entry.inStationCode = Int(data[7]) & 0xFF
|
||||
entry.outLineCode = Int(data[8]) & 0xFF
|
||||
entry.outStationCode = Int(data[9]) & 0xFF
|
||||
|
||||
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])
|
||||
// Balance: little-endian bytes 10-11
|
||||
entry.balance = (Int(data[10]) & 0xFF) | ((Int(data[11]) & 0xFF) << 8)
|
||||
|
||||
return [
|
||||
"terminalType" : terminalType,
|
||||
"processType" : processType,
|
||||
"processNumber" : processNumber,
|
||||
"year" : year,
|
||||
"month" : month,
|
||||
"day" : day,
|
||||
"balance" : balance,
|
||||
"inLineCode" : inLineCode,
|
||||
"inStationCode" : inStationCode,
|
||||
"outLineCode" : outLineCode,
|
||||
"outStationCode" : outStationCode,
|
||||
"companyCode" : Int(block[13]),
|
||||
"regionCode" : regionCode
|
||||
]
|
||||
// companyCode at byte 13, regionCode at byte 15 — matches Android
|
||||
entry.companyCode = Int(data[13]) & 0xFF
|
||||
entry.regionCode = Int(data[15]) & 0xFF
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
private func finishScan(session: NFCTagReaderSession, idm: String, systemCode: String,
|
||||
balance: Int, entries: [HistoryEntryRecord]) {
|
||||
session.alertMessage = "読み取り完了"
|
||||
session.invalidate()
|
||||
|
||||
let result = ScanResultRecord()
|
||||
result.idm = idm
|
||||
result.balance = balance
|
||||
result.systemCode = systemCode
|
||||
result.history = entries
|
||||
self.promise.resolve(result)
|
||||
self.session = nil
|
||||
self.onComplete()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { requireNativeModule } from 'expo-modules-core';
|
||||
import { requireOptionalNativeModule } from 'expo-modules-core';
|
||||
|
||||
// It loads the native module object from the JSI or falls back to
|
||||
// the bridge module (from NativeModulesProxy) if the remote debugger is on.
|
||||
export default requireNativeModule('ExpoFelicaReader');
|
||||
// requireOptionalNativeModule returns null when native module is unavailable
|
||||
// (e.g. running in Expo Go or simulator without native rebuild).
|
||||
export default requireOptionalNativeModule('ExpoFelicaReader');
|
||||
|
||||
@@ -69,6 +69,47 @@ export interface FelicaCardInfo {
|
||||
* @returns FelicaCardInfo
|
||||
* @throws NFC 非対応デバイスやスキャンエラー時は reject される
|
||||
*/
|
||||
export function ping(): string | null {
|
||||
if (!ExpoFelicaReaderModule) return null;
|
||||
return ExpoFelicaReaderModule.ping();
|
||||
}
|
||||
|
||||
/**
|
||||
* このデバイスが NFC (FeliCa) 読み取りに対応しているかを返す。
|
||||
* - Android: NfcAdapter が取得できるかどうか
|
||||
* - iOS: NFCTagReaderSession.readingAvailable
|
||||
* - Web / ネイティブモジュール未ロード時: false
|
||||
*/
|
||||
export function isAvailable(): boolean {
|
||||
if (!ExpoFelicaReaderModule) return false;
|
||||
return ExpoFelicaReaderModule.isAvailable() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS ウィジェット用に App Group UserDefaults にデータを保存する。
|
||||
* Android / Web では何もしない。
|
||||
*/
|
||||
export function saveWidgetData(key: string, value: object): void {
|
||||
if (!ExpoFelicaReaderModule?.saveWidgetData) return;
|
||||
ExpoFelicaReaderModule.saveWidgetData(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
export async function scan(): Promise<FelicaCardInfo> {
|
||||
if (!ExpoFelicaReaderModule) {
|
||||
const g = globalThis as any;
|
||||
const expoExists = !!g.expo;
|
||||
const modulesObj = g.expo?.modules;
|
||||
const allKeys = modulesObj ? Object.keys(modulesObj) : [];
|
||||
const expoKeys = allKeys.filter((k: string) => k.startsWith('Expo'));
|
||||
const directLookup = modulesObj?.ExpoFelicaReader;
|
||||
throw new Error(
|
||||
'ExpoFelicaReader native module is not available on this device.\n' +
|
||||
`expo exists: ${expoExists}\n` +
|
||||
`modules object type: ${typeof modulesObj}\n` +
|
||||
`total keys: ${allKeys.length}\n` +
|
||||
`direct lookup: ${typeof directLookup} (${directLookup})\n` +
|
||||
`Expo* modules(${expoKeys.length}): ${expoKeys.join(', ')}`
|
||||
);
|
||||
}
|
||||
return await ExpoFelicaReaderModule.scan();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
apply plugin: 'com.android.library'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'maven-publish'
|
||||
|
||||
group = 'expo.modules.liveactivity'
|
||||
version = '0.1.0'
|
||||
|
||||
buildscript {
|
||||
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||
if (expoModulesCorePlugin.exists()) {
|
||||
apply from: expoModulesCorePlugin
|
||||
applyKotlinExpoModulesCorePlugin()
|
||||
}
|
||||
|
||||
ext.safeExtGet = { prop, fallback ->
|
||||
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
||||
}
|
||||
|
||||
ext.getKotlinVersion = {
|
||||
if (ext.has("kotlinVersion")) {
|
||||
ext.kotlinVersion()
|
||||
} else {
|
||||
ext.safeExtGet("kotlinVersion", "1.8.10")
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${getKotlinVersion()}")
|
||||
}
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
publishing {
|
||||
publications {
|
||||
release(MavenPublication) {
|
||||
from components.release
|
||||
}
|
||||
}
|
||||
repositories {
|
||||
maven {
|
||||
url = mavenLocal().url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion safeExtGet("compileSdkVersion", 36)
|
||||
|
||||
def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION
|
||||
if (agpVersion.tokenize('.')[0].toInteger() < 8) {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17.majorVersion
|
||||
}
|
||||
}
|
||||
|
||||
namespace "expo.modules.liveactivity"
|
||||
defaultConfig {
|
||||
minSdkVersion safeExtGet("minSdkVersion", 21)
|
||||
targetSdkVersion safeExtGet("targetSdkVersion", 35)
|
||||
versionCode 1
|
||||
versionName "0.1.0"
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
publishing {
|
||||
singleVariant("release") {
|
||||
withSourcesJar()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':expo-modules-core')
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}"
|
||||
implementation "androidx.core:core-ktx:1.16.0"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />
|
||||
|
||||
<application>
|
||||
<service
|
||||
android:name="expo.modules.liveactivity.LiveActivityForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="realtime_train_tracking" />
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
package expo.modules.liveactivity
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.core.content.ContextCompat
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
import expo.modules.kotlin.Promise
|
||||
|
||||
class ExpoLiveActivityModule : Module() {
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("ExpoLiveActivity")
|
||||
|
||||
Function("isAvailable") {
|
||||
true
|
||||
}
|
||||
|
||||
Function("isLiveNotificationActive") {
|
||||
LiveActivityForegroundService.isRunning
|
||||
}
|
||||
|
||||
AsyncFunction("startTrainFollowNotification") { title: String, body: String, color: String, progressCurrent: Int, progressTotal: Int, promise: Promise ->
|
||||
val context = appContext.reactContext ?: run {
|
||||
promise.reject("NO_CONTEXT", "React context is not available", null)
|
||||
return@AsyncFunction
|
||||
}
|
||||
val intent = Intent(context, LiveActivityForegroundService::class.java).apply {
|
||||
action = LiveActivityForegroundService.ACTION_START_TRAIN
|
||||
putExtra("title", title)
|
||||
putExtra("body", body)
|
||||
putExtra("color", color)
|
||||
putExtra("progressCurrent", progressCurrent)
|
||||
putExtra("progressTotal", progressTotal)
|
||||
}
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
promise.resolve("android-train")
|
||||
}
|
||||
|
||||
AsyncFunction("updateTrainFollowNotification") { title: String, body: String, color: String, progressCurrent: Int, progressTotal: Int, promise: Promise ->
|
||||
val context = appContext.reactContext ?: run {
|
||||
promise.reject("NO_CONTEXT", "React context is not available", null)
|
||||
return@AsyncFunction
|
||||
}
|
||||
val intent = Intent(context, LiveActivityForegroundService::class.java).apply {
|
||||
action = LiveActivityForegroundService.ACTION_UPDATE_TRAIN
|
||||
putExtra("title", title)
|
||||
putExtra("body", body)
|
||||
putExtra("color", color)
|
||||
putExtra("progressCurrent", progressCurrent)
|
||||
putExtra("progressTotal", progressTotal)
|
||||
}
|
||||
context.startService(intent)
|
||||
promise.resolve(null)
|
||||
}
|
||||
|
||||
AsyncFunction("stopTrainFollowNotification") { promise: Promise ->
|
||||
val context = appContext.reactContext ?: run {
|
||||
promise.reject("NO_CONTEXT", "React context is not available", null)
|
||||
return@AsyncFunction
|
||||
}
|
||||
context.stopService(Intent(context, LiveActivityForegroundService::class.java))
|
||||
promise.resolve(null)
|
||||
}
|
||||
|
||||
AsyncFunction("startStationLockNotification") { title: String, linesJson: String, color: String, summary: String, promise: Promise ->
|
||||
val context = appContext.reactContext ?: run {
|
||||
promise.reject("NO_CONTEXT", "React context is not available", null)
|
||||
return@AsyncFunction
|
||||
}
|
||||
val intent = Intent(context, LiveActivityForegroundService::class.java).apply {
|
||||
action = LiveActivityForegroundService.ACTION_START_STATION
|
||||
putExtra("title", title)
|
||||
putExtra("linesJson", linesJson)
|
||||
putExtra("color", color)
|
||||
putExtra("summary", summary)
|
||||
}
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
promise.resolve("android-station")
|
||||
}
|
||||
|
||||
AsyncFunction("updateStationLockNotification") { title: String, linesJson: String, color: String, summary: String, promise: Promise ->
|
||||
val context = appContext.reactContext ?: run {
|
||||
promise.reject("NO_CONTEXT", "React context is not available", null)
|
||||
return@AsyncFunction
|
||||
}
|
||||
val intent = Intent(context, LiveActivityForegroundService::class.java).apply {
|
||||
action = LiveActivityForegroundService.ACTION_UPDATE_STATION
|
||||
putExtra("title", title)
|
||||
putExtra("linesJson", linesJson)
|
||||
putExtra("color", color)
|
||||
putExtra("summary", summary)
|
||||
}
|
||||
context.startService(intent)
|
||||
promise.resolve(null)
|
||||
}
|
||||
|
||||
AsyncFunction("stopStationLockNotification") { promise: Promise ->
|
||||
val context = appContext.reactContext ?: run {
|
||||
promise.reject("NO_CONTEXT", "React context is not available", null)
|
||||
return@AsyncFunction
|
||||
}
|
||||
context.stopService(Intent(context, LiveActivityForegroundService::class.java))
|
||||
promise.resolve(null)
|
||||
}
|
||||
|
||||
AsyncFunction("endAllActivities") { promise: Promise ->
|
||||
val context = appContext.reactContext ?: run {
|
||||
promise.reject("NO_CONTEXT", "React context is not available", null)
|
||||
return@AsyncFunction
|
||||
}
|
||||
context.stopService(Intent(context, LiveActivityForegroundService::class.java))
|
||||
promise.resolve(null)
|
||||
}
|
||||
|
||||
Function("getActiveTrainFollowActivities") {
|
||||
if (LiveActivityForegroundService.isRunning && LiveActivityForegroundService.currentMode == "train") {
|
||||
listOf("android-train")
|
||||
} else {
|
||||
emptyList<String>()
|
||||
}
|
||||
}
|
||||
|
||||
Function("getActiveStationLockActivities") {
|
||||
if (LiveActivityForegroundService.isRunning && LiveActivityForegroundService.currentMode == "station") {
|
||||
listOf("android-station")
|
||||
} else {
|
||||
emptyList<String>()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
package expo.modules.liveactivity
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import org.json.JSONArray
|
||||
|
||||
class LiveActivityForegroundService : Service() {
|
||||
companion object {
|
||||
const val ACTION_START_TRAIN = "ACTION_START_TRAIN"
|
||||
const val ACTION_UPDATE_TRAIN = "ACTION_UPDATE_TRAIN"
|
||||
const val ACTION_START_STATION = "ACTION_START_STATION"
|
||||
const val ACTION_UPDATE_STATION = "ACTION_UPDATE_STATION"
|
||||
const val ACTION_STOP = "ACTION_STOP"
|
||||
const val CHANNEL_ID = "live_tracking"
|
||||
const val NOTIFICATION_ID = 8001
|
||||
|
||||
@Volatile
|
||||
var isRunning = false
|
||||
private set
|
||||
|
||||
@Volatile
|
||||
var currentMode: String = ""
|
||||
private set
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
ensureNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_START_TRAIN -> {
|
||||
val title = intent.getStringExtra("title") ?: ""
|
||||
val body = intent.getStringExtra("body") ?: ""
|
||||
val color = intent.getStringExtra("color") ?: "#00B8FF"
|
||||
val progressCurrent = intent.getIntExtra("progressCurrent", 0)
|
||||
val progressTotal = intent.getIntExtra("progressTotal", 0)
|
||||
val notification = TrainFollowNotificationBuilder.build(
|
||||
this, title, body, color,
|
||||
progressCurrent, progressTotal,
|
||||
buildStopPendingIntent(), buildContentPendingIntent()
|
||||
)
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
isRunning = true
|
||||
currentMode = "train"
|
||||
}
|
||||
ACTION_UPDATE_TRAIN -> {
|
||||
if (!isRunning) return START_NOT_STICKY
|
||||
val title = intent.getStringExtra("title") ?: ""
|
||||
val body = intent.getStringExtra("body") ?: ""
|
||||
val color = intent.getStringExtra("color") ?: "#00B8FF"
|
||||
val progressCurrent = intent.getIntExtra("progressCurrent", 0)
|
||||
val progressTotal = intent.getIntExtra("progressTotal", 0)
|
||||
val notification = TrainFollowNotificationBuilder.build(
|
||||
this, title, body, color,
|
||||
progressCurrent, progressTotal,
|
||||
buildStopPendingIntent(), buildContentPendingIntent()
|
||||
)
|
||||
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
ACTION_START_STATION -> {
|
||||
val title = intent.getStringExtra("title") ?: ""
|
||||
val linesJson = intent.getStringExtra("linesJson") ?: "[]"
|
||||
val color = intent.getStringExtra("color") ?: "#00B8FF"
|
||||
val summary = intent.getStringExtra("summary") ?: ""
|
||||
val lines = parseLinesJson(linesJson)
|
||||
val notification = StationLockNotificationBuilder.build(
|
||||
this, title, lines, color, summary,
|
||||
buildStopPendingIntent(), buildContentPendingIntent()
|
||||
)
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
isRunning = true
|
||||
currentMode = "station"
|
||||
}
|
||||
ACTION_UPDATE_STATION -> {
|
||||
if (!isRunning) return START_NOT_STICKY
|
||||
val title = intent.getStringExtra("title") ?: ""
|
||||
val linesJson = intent.getStringExtra("linesJson") ?: "[]"
|
||||
val color = intent.getStringExtra("color") ?: "#00B8FF"
|
||||
val summary = intent.getStringExtra("summary") ?: ""
|
||||
val lines = parseLinesJson(linesJson)
|
||||
val notification = StationLockNotificationBuilder.build(
|
||||
this, title, lines, color, summary,
|
||||
buildStopPendingIntent(), buildContentPendingIntent()
|
||||
)
|
||||
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
ACTION_STOP -> {
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
isRunning = false
|
||||
currentMode = ""
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun ensureNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (nm.getNotificationChannel(CHANNEL_ID) == null) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"列車追従・駅ロック",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "列車追従・駅ロックのライブ通知"
|
||||
setShowBadge(false)
|
||||
}
|
||||
nm.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildStopPendingIntent(): PendingIntent {
|
||||
val stopIntent = Intent(this, LiveActivityForegroundService::class.java).apply {
|
||||
action = ACTION_STOP
|
||||
}
|
||||
return PendingIntent.getService(
|
||||
this, 0, stopIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildContentPendingIntent(): PendingIntent {
|
||||
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED
|
||||
} ?: Intent()
|
||||
return PendingIntent.getActivity(
|
||||
this, 0, launchIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseLinesJson(json: String): List<String> {
|
||||
return try {
|
||||
val arr = JSONArray(json)
|
||||
(0 until arr.length()).map { arr.getString(it) }
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package expo.modules.liveactivity
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
object StationLockNotificationBuilder {
|
||||
fun build(
|
||||
context: Context,
|
||||
title: String,
|
||||
lines: List<String>,
|
||||
colorHex: String,
|
||||
summary: String,
|
||||
stopAction: PendingIntent,
|
||||
contentIntent: PendingIntent
|
||||
): Notification {
|
||||
val color = try {
|
||||
Color.parseColor(colorHex)
|
||||
} catch (_: Exception) {
|
||||
Color.parseColor("#00B8FF")
|
||||
}
|
||||
|
||||
val iconRes = context.resources.getIdentifier(
|
||||
"ic_notification", "drawable", context.packageName
|
||||
)
|
||||
val icon = if (iconRes != 0) iconRes else android.R.drawable.ic_menu_myplaces
|
||||
|
||||
val inboxStyle = NotificationCompat.InboxStyle()
|
||||
lines.take(7).forEach { inboxStyle.addLine(it) }
|
||||
if (summary.isNotEmpty()) {
|
||||
inboxStyle.setSummaryText(summary)
|
||||
}
|
||||
inboxStyle.setBigContentTitle(title)
|
||||
|
||||
return NotificationCompat.Builder(context, LiveActivityForegroundService.CHANNEL_ID)
|
||||
.setSmallIcon(icon)
|
||||
.setContentTitle(title)
|
||||
.setContentText(lines.firstOrNull() ?: "")
|
||||
.setStyle(inboxStyle)
|
||||
.setSubText("駅情報")
|
||||
.setColor(color)
|
||||
.setColorized(true)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(contentIntent)
|
||||
.addAction(
|
||||
android.R.drawable.ic_menu_close_clear_cancel,
|
||||
"ロックを解除",
|
||||
stopAction
|
||||
)
|
||||
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package expo.modules.liveactivity
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.NotificationCompat
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
object TrainFollowNotificationBuilder {
|
||||
fun build(
|
||||
context: Context,
|
||||
title: String,
|
||||
body: String,
|
||||
colorHex: String,
|
||||
progressCurrent: Int,
|
||||
progressTotal: Int,
|
||||
stopAction: PendingIntent,
|
||||
contentIntent: PendingIntent
|
||||
): Notification {
|
||||
val color = try {
|
||||
Color.parseColor(colorHex)
|
||||
} catch (_: Exception) {
|
||||
Color.parseColor("#00B8FF")
|
||||
}
|
||||
|
||||
val iconRes = context.resources.getIdentifier(
|
||||
"ic_notification", "drawable", context.packageName
|
||||
)
|
||||
val icon = if (iconRes != 0) iconRes else android.R.drawable.ic_menu_mylocation
|
||||
|
||||
val builder = NotificationCompat.Builder(context, LiveActivityForegroundService.CHANNEL_ID)
|
||||
.setSmallIcon(icon)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body.split("\n").firstOrNull() ?: "")
|
||||
.setColor(color)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(contentIntent)
|
||||
.addAction(
|
||||
android.R.drawable.ic_menu_close_clear_cancel,
|
||||
"追跡を停止",
|
||||
stopAction
|
||||
)
|
||||
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA && progressTotal > 1) {
|
||||
builder.setRequestPromotedOngoing(true)
|
||||
builder.setStyle(buildProgressStyle(color, progressCurrent, progressTotal))
|
||||
} else {
|
||||
builder.setStyle(
|
||||
NotificationCompat.BigTextStyle()
|
||||
.bigText(body)
|
||||
.setSummaryText("列車追跡中")
|
||||
)
|
||||
builder.setSubText("列車追跡中")
|
||||
builder.setColorized(true)
|
||||
if (progressTotal > 0) {
|
||||
builder.setProgress(progressTotal, progressCurrent, false)
|
||||
}
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.BAKLAVA)
|
||||
private fun buildProgressStyle(
|
||||
trainColor: Int,
|
||||
current: Int,
|
||||
total: Int
|
||||
): NotificationCompat.ProgressStyle {
|
||||
val numSegments = total - 1
|
||||
val currentZeroBased = (current - 1).coerceIn(0, total - 1)
|
||||
|
||||
val positions = (0 until total).map { i ->
|
||||
if (numSegments == 0) 0
|
||||
else (i * 100.0 / numSegments).roundToInt().coerceIn(0, 100)
|
||||
}
|
||||
|
||||
val dimColor = Color.argb(
|
||||
60,
|
||||
Color.red(trainColor),
|
||||
Color.green(trainColor),
|
||||
Color.blue(trainColor)
|
||||
)
|
||||
|
||||
val segments = (0 until numSegments).map { i ->
|
||||
val length = positions[i + 1] - positions[i]
|
||||
val segColor = if (i < currentZeroBased) trainColor else dimColor
|
||||
NotificationCompat.ProgressStyle.Segment(length).setColor(segColor)
|
||||
}
|
||||
|
||||
val points = positions.mapIndexed { i, pos ->
|
||||
val ptColor = if (i <= currentZeroBased) trainColor else dimColor
|
||||
NotificationCompat.ProgressStyle.Point(pos).setColor(ptColor)
|
||||
}
|
||||
|
||||
val progressValue = positions[currentZeroBased]
|
||||
|
||||
return NotificationCompat.ProgressStyle()
|
||||
.setProgressSegments(segments)
|
||||
.setProgressPoints(points)
|
||||
.setProgress(progressValue)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"platforms": ["ios", "android"],
|
||||
"ios": {
|
||||
"modules": ["ExpoLiveActivityModule"]
|
||||
},
|
||||
"android": {
|
||||
"modules": ["expo.modules.liveactivity.ExpoLiveActivityModule"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
require 'json'
|
||||
|
||||
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
|
||||
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'ExpoLiveActivity'
|
||||
s.version = package['version']
|
||||
s.summary = package['description']
|
||||
s.description = package['description']
|
||||
s.license = "MIT"
|
||||
s.author = "harukin"
|
||||
s.homepage = "https://github.com/harukin/jrshikoku"
|
||||
s.platform = :ios, '16.2'
|
||||
s.swift_version = '5.9'
|
||||
s.source = { git: '' }
|
||||
s.static_framework = true
|
||||
|
||||
s.dependency 'ExpoModulesCore'
|
||||
s.frameworks = 'ActivityKit'
|
||||
|
||||
s.pod_target_xcconfig = {
|
||||
'DEFINES_MODULE' => 'YES',
|
||||
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||
}
|
||||
|
||||
s.source_files = "*.{h,m,swift}"
|
||||
end
|
||||
@@ -0,0 +1,215 @@
|
||||
import ActivityKit
|
||||
import ExpoModulesCore
|
||||
|
||||
// MARK: - Attribute mirror types (must match widget target definitions)
|
||||
|
||||
private struct TrainFollowAttributes: ActivityAttributes {
|
||||
struct ContentState: Codable, Hashable {
|
||||
var currentStation: String
|
||||
var nextStation: String
|
||||
var delayMinutes: Int
|
||||
var scheduledArrival: String
|
||||
var updatedAt: Date
|
||||
}
|
||||
|
||||
var trainNumber: String
|
||||
var lineName: String
|
||||
var destination: String
|
||||
}
|
||||
|
||||
private struct StationLockAttributes: ActivityAttributes {
|
||||
struct ContentState: Codable, Hashable {
|
||||
var nextTrainTime: String
|
||||
var nextTrainDestination: String
|
||||
var nextTrainPlatform: String
|
||||
var followingTrainTime: String
|
||||
var followingTrainDestination: String
|
||||
var updatedAt: Date
|
||||
}
|
||||
|
||||
var stationName: String
|
||||
}
|
||||
|
||||
// MARK: - Record types for JS ↔ Swift bridging
|
||||
|
||||
struct TrainFollowParamsRecord: Record {
|
||||
@Field var trainNumber: String = ""
|
||||
@Field var lineName: String = ""
|
||||
@Field var destination: String = ""
|
||||
@Field var currentStation: String = ""
|
||||
@Field var nextStation: String = ""
|
||||
@Field var delayMinutes: Int = 0
|
||||
@Field var scheduledArrival: String = ""
|
||||
}
|
||||
|
||||
struct TrainFollowStateRecord: Record {
|
||||
@Field var currentStation: String = ""
|
||||
@Field var nextStation: String = ""
|
||||
@Field var delayMinutes: Int = 0
|
||||
@Field var scheduledArrival: String = ""
|
||||
}
|
||||
|
||||
struct StationLockParamsRecord: Record {
|
||||
@Field var stationName: String = ""
|
||||
@Field var nextTrainTime: String = ""
|
||||
@Field var nextTrainDestination: String = ""
|
||||
@Field var nextTrainPlatform: String = ""
|
||||
@Field var followingTrainTime: String = ""
|
||||
@Field var followingTrainDestination: String = ""
|
||||
}
|
||||
|
||||
struct StationLockStateRecord: Record {
|
||||
@Field var nextTrainTime: String = ""
|
||||
@Field var nextTrainDestination: String = ""
|
||||
@Field var nextTrainPlatform: String = ""
|
||||
@Field var followingTrainTime: String = ""
|
||||
@Field var followingTrainDestination: String = ""
|
||||
}
|
||||
|
||||
// MARK: - Module
|
||||
|
||||
public class ExpoLiveActivityModule: Module {
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("ExpoLiveActivity")
|
||||
|
||||
// MARK: Availability
|
||||
|
||||
Function("isAvailable") { () -> Bool in
|
||||
ActivityAuthorizationInfo().areActivitiesEnabled
|
||||
}
|
||||
|
||||
// MARK: 列車追従 Live Activity
|
||||
|
||||
AsyncFunction("startTrainFollowActivity") { (params: TrainFollowParamsRecord, promise: Promise) in
|
||||
let attributes = TrainFollowAttributes(
|
||||
trainNumber: params.trainNumber,
|
||||
lineName: params.lineName,
|
||||
destination: params.destination
|
||||
)
|
||||
let state = TrainFollowAttributes.ContentState(
|
||||
currentStation: params.currentStation,
|
||||
nextStation: params.nextStation,
|
||||
delayMinutes: params.delayMinutes,
|
||||
scheduledArrival: params.scheduledArrival,
|
||||
updatedAt: Date()
|
||||
)
|
||||
do {
|
||||
let activity = try Activity<TrainFollowAttributes>.request(
|
||||
attributes: attributes,
|
||||
content: .init(state: state, staleDate: nil),
|
||||
pushType: nil
|
||||
)
|
||||
promise.resolve(activity.id)
|
||||
} catch {
|
||||
promise.reject("ERR_LIVE_ACTIVITY", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
AsyncFunction("updateTrainFollowActivity") { (activityId: String, state: TrainFollowStateRecord, promise: Promise) in
|
||||
guard let activity = Activity<TrainFollowAttributes>.activities.first(where: { $0.id == activityId }) else {
|
||||
promise.reject("ERR_LIVE_ACTIVITY", "Activity not found: \(activityId)")
|
||||
return
|
||||
}
|
||||
let newState = TrainFollowAttributes.ContentState(
|
||||
currentStation: state.currentStation,
|
||||
nextStation: state.nextStation,
|
||||
delayMinutes: state.delayMinutes,
|
||||
scheduledArrival: state.scheduledArrival,
|
||||
updatedAt: Date()
|
||||
)
|
||||
Task {
|
||||
await activity.update(ActivityContent(state: newState, staleDate: nil))
|
||||
promise.resolve(nil)
|
||||
}
|
||||
}
|
||||
|
||||
AsyncFunction("endTrainFollowActivity") { (activityId: String, promise: Promise) in
|
||||
guard let activity = Activity<TrainFollowAttributes>.activities.first(where: { $0.id == activityId }) else {
|
||||
promise.reject("ERR_LIVE_ACTIVITY", "Activity not found: \(activityId)")
|
||||
return
|
||||
}
|
||||
Task {
|
||||
await activity.end(dismissalPolicy: .immediate)
|
||||
promise.resolve(nil)
|
||||
}
|
||||
}
|
||||
|
||||
Function("getActiveTrainFollowActivities") { () -> [String] in
|
||||
Activity<TrainFollowAttributes>.activities.map { $0.id }
|
||||
}
|
||||
|
||||
// MARK: 駅ロック Live Activity
|
||||
|
||||
AsyncFunction("startStationLockActivity") { (params: StationLockParamsRecord, promise: Promise) in
|
||||
let attributes = StationLockAttributes(stationName: params.stationName)
|
||||
let state = StationLockAttributes.ContentState(
|
||||
nextTrainTime: params.nextTrainTime,
|
||||
nextTrainDestination: params.nextTrainDestination,
|
||||
nextTrainPlatform: params.nextTrainPlatform,
|
||||
followingTrainTime: params.followingTrainTime,
|
||||
followingTrainDestination: params.followingTrainDestination,
|
||||
updatedAt: Date()
|
||||
)
|
||||
do {
|
||||
let activity = try Activity<StationLockAttributes>.request(
|
||||
attributes: attributes,
|
||||
content: .init(state: state, staleDate: nil),
|
||||
pushType: nil
|
||||
)
|
||||
promise.resolve(activity.id)
|
||||
} catch {
|
||||
promise.reject("ERR_LIVE_ACTIVITY", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
AsyncFunction("updateStationLockActivity") { (activityId: String, state: StationLockStateRecord, promise: Promise) in
|
||||
guard let activity = Activity<StationLockAttributes>.activities.first(where: { $0.id == activityId }) else {
|
||||
promise.reject("ERR_LIVE_ACTIVITY", "Activity not found: \(activityId)")
|
||||
return
|
||||
}
|
||||
let newState = StationLockAttributes.ContentState(
|
||||
nextTrainTime: state.nextTrainTime,
|
||||
nextTrainDestination: state.nextTrainDestination,
|
||||
nextTrainPlatform: state.nextTrainPlatform,
|
||||
followingTrainTime: state.followingTrainTime,
|
||||
followingTrainDestination: state.followingTrainDestination,
|
||||
updatedAt: Date()
|
||||
)
|
||||
Task {
|
||||
await activity.update(ActivityContent(state: newState, staleDate: nil))
|
||||
promise.resolve(nil)
|
||||
}
|
||||
}
|
||||
|
||||
AsyncFunction("endStationLockActivity") { (activityId: String, promise: Promise) in
|
||||
guard let activity = Activity<StationLockAttributes>.activities.first(where: { $0.id == activityId }) else {
|
||||
promise.reject("ERR_LIVE_ACTIVITY", "Activity not found: \(activityId)")
|
||||
return
|
||||
}
|
||||
Task {
|
||||
await activity.end(dismissalPolicy: .immediate)
|
||||
promise.resolve(nil)
|
||||
}
|
||||
}
|
||||
|
||||
Function("getActiveStationLockActivities") { () -> [String] in
|
||||
Activity<StationLockAttributes>.activities.map { $0.id }
|
||||
}
|
||||
|
||||
// MARK: End all
|
||||
|
||||
AsyncFunction("endAllActivities") { (promise: Promise) in
|
||||
Task {
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for activity in Activity<TrainFollowAttributes>.activities {
|
||||
group.addTask { await activity.end(dismissalPolicy: .immediate) }
|
||||
}
|
||||
for activity in Activity<StationLockAttributes>.activities {
|
||||
group.addTask { await activity.end(dismissalPolicy: .immediate) }
|
||||
}
|
||||
}
|
||||
promise.resolve(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "expo-live-activity",
|
||||
"version": "0.1.0",
|
||||
"description": "Expo module for managing Live Activities (列車追従 / 駅ロック)",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { requireOptionalNativeModule } from 'expo-modules-core';
|
||||
|
||||
export default requireOptionalNativeModule('ExpoLiveActivity');
|
||||
@@ -0,0 +1,369 @@
|
||||
import { Platform } from 'react-native';
|
||||
import ExpoLiveActivityModule from './ExpoLiveActivityModule';
|
||||
|
||||
console.log('[ExpoLiveActivity] TOP OF FILE - module:', ExpoLiveActivityModule, 'Platform:', Platform.OS);
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
export interface TrainFollowParams {
|
||||
/** 列車番号 (例: "2001D") */
|
||||
trainNumber: string;
|
||||
/** 路線名 (例: "土讃線") */
|
||||
lineName: string;
|
||||
/** 行き先 (例: "高知") */
|
||||
destination: string;
|
||||
/** 現在駅 */
|
||||
currentStation: string;
|
||||
/** 次の停車駅 */
|
||||
nextStation: string;
|
||||
/** 遅延分数 (0 = 定刻) */
|
||||
delayMinutes: number;
|
||||
/** 終着到着予定時刻 "HH:mm" */
|
||||
scheduledArrival: string;
|
||||
/** 列車種別名 (例: "快速") — Android通知用 */
|
||||
trainType?: string;
|
||||
/** 列車名 (例: "マリンライナー 56号") — Android通知用 */
|
||||
trainName?: string;
|
||||
/** 列車種別色 (例: "#FF6600") — Android通知用 */
|
||||
trainTypeColor?: string;
|
||||
/** 位置ステータス ("次は" or "ただいま") — Android通知用 */
|
||||
positionStatus?: string;
|
||||
/** 遅延テキスト (例: "3分遅れ" or "定刻") — Android通知用 */
|
||||
delayStatus?: string;
|
||||
/** 次駅までの推定分数 — Android通知用 */
|
||||
estimatedMinutes?: number;
|
||||
/** 停車駅名リスト (通過駅除外) — Android通知用 */
|
||||
stationStops?: string[];
|
||||
/** 次の停車駅のインデックス (0-based) — Android通知用 */
|
||||
nextStationIndex?: number;
|
||||
}
|
||||
|
||||
export interface TrainFollowState {
|
||||
currentStation: string;
|
||||
nextStation: string;
|
||||
delayMinutes: number;
|
||||
scheduledArrival: string;
|
||||
/** 列車種別名 — Android通知用 */
|
||||
trainType?: string;
|
||||
/** 列車名 — Android通知用 */
|
||||
trainName?: string;
|
||||
/** 列車種別色 — Android通知用 */
|
||||
trainTypeColor?: string;
|
||||
/** 行き先 — Android通知用 */
|
||||
destination?: string;
|
||||
/** 位置ステータス — Android通知用 */
|
||||
positionStatus?: string;
|
||||
/** 遅延テキスト — Android通知用 */
|
||||
delayStatus?: string;
|
||||
/** 次駅までの推定分数 — Android通知用 */
|
||||
estimatedMinutes?: number;
|
||||
/** 停車駅名リスト (通過駅除外) — Android通知用 */
|
||||
stationStops?: string[];
|
||||
/** 次の停車駅のインデックス (0-based) — Android通知用 */
|
||||
nextStationIndex?: number;
|
||||
}
|
||||
|
||||
export interface StationLockParams {
|
||||
/** 駅名 */
|
||||
stationName: string;
|
||||
/** 次の列車の発車時刻 "HH:mm" */
|
||||
nextTrainTime: string;
|
||||
/** 次の列車の行き先 */
|
||||
nextTrainDestination: string;
|
||||
/** 次の列車のホーム番線 (例: "1番線") */
|
||||
nextTrainPlatform: string;
|
||||
/** その次の列車の発車時刻 "HH:mm"(なければ空文字) */
|
||||
followingTrainTime: string;
|
||||
/** その次の列車の行き先 */
|
||||
followingTrainDestination: string;
|
||||
/** 駅番号 (例: "K25") — Android通知用 */
|
||||
stationNumber?: string;
|
||||
/** 路線色 — Android通知用 */
|
||||
lineColor?: string;
|
||||
/** 発車予定の列車一覧 — Android通知用 */
|
||||
trains?: StationTrainInfo[];
|
||||
}
|
||||
|
||||
export interface StationLockState {
|
||||
nextTrainTime: string;
|
||||
nextTrainDestination: string;
|
||||
nextTrainPlatform: string;
|
||||
followingTrainTime: string;
|
||||
followingTrainDestination: string;
|
||||
/** 駅名 — Android通知用 */
|
||||
stationName?: string;
|
||||
/** 駅番号 — Android通知用 */
|
||||
stationNumber?: string;
|
||||
/** 路線色 — Android通知用 */
|
||||
lineColor?: string;
|
||||
/** 発車予定の列車一覧 — Android通知用 */
|
||||
trains?: StationTrainInfo[];
|
||||
}
|
||||
|
||||
export interface StationTrainInfo {
|
||||
time: string;
|
||||
typeName: string;
|
||||
trainName: string;
|
||||
destination: string;
|
||||
platform: string;
|
||||
delayStatus: string;
|
||||
}
|
||||
|
||||
// MARK: - Android Notification Formatters
|
||||
|
||||
function buildTrainFollowTitle(params: {
|
||||
trainType?: string;
|
||||
trainName?: string;
|
||||
destination?: string;
|
||||
trainNumber?: string;
|
||||
}): string {
|
||||
const type = params.trainType ? `[${params.trainType}] ` : '';
|
||||
const name = params.trainName || params.trainNumber || '';
|
||||
const dest = params.destination ? ` →${params.destination}` : '';
|
||||
return `${type}${name}${dest}`;
|
||||
}
|
||||
|
||||
function buildStationProgressLine(
|
||||
stationStops?: string[],
|
||||
nextStationIndex?: number
|
||||
): string {
|
||||
if (!stationStops || stationStops.length === 0 || nextStationIndex == null) return '';
|
||||
const total = stationStops.length;
|
||||
const idx = Math.max(0, Math.min(nextStationIndex, total - 1));
|
||||
|
||||
// ● = passed, ◉ = next station, ○ = upcoming
|
||||
const dots = stationStops.map((_, i) => {
|
||||
if (i < idx) return '●';
|
||||
if (i === idx) return '◉';
|
||||
return '○';
|
||||
});
|
||||
|
||||
// Compact: if > 20 stops, abbreviate
|
||||
if (total > 20) {
|
||||
const passed = idx;
|
||||
const remaining = total - idx - 1;
|
||||
return `${'●'.repeat(Math.min(passed, 3))}${passed > 3 ? '…' : ''}◉${'○'.repeat(Math.min(remaining, 3))}${remaining > 3 ? '…' : ''} (${idx + 1}/${total})`;
|
||||
}
|
||||
|
||||
const connector = total <= 15 ? '─' : '';
|
||||
return `${dots.join(connector)} (${idx + 1}/${total})`;
|
||||
}
|
||||
|
||||
function buildTrainFollowBody(params: {
|
||||
positionStatus?: string;
|
||||
nextStation?: string;
|
||||
estimatedMinutes?: number;
|
||||
delayStatus?: string;
|
||||
stationStops?: string[];
|
||||
nextStationIndex?: number;
|
||||
}): string {
|
||||
const lines: string[] = [];
|
||||
const progress = buildStationProgressLine(params.stationStops, params.nextStationIndex);
|
||||
if (params.nextStation) {
|
||||
// 折り畳み表示でも進捗が見えるよう、最初の行に進捗を含める
|
||||
const stationLine = `${params.positionStatus || '次は'} ${params.nextStation}`;
|
||||
lines.push(progress ? `${stationLine} ${progress}` : stationLine);
|
||||
} else if (progress) {
|
||||
lines.push(progress);
|
||||
}
|
||||
if (params.estimatedMinutes != null && params.estimatedMinutes > 0) {
|
||||
lines.push(`約${params.estimatedMinutes}分後到着予定`);
|
||||
}
|
||||
if (params.delayStatus) {
|
||||
lines.push(params.delayStatus);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildStationLockTitle(params: {
|
||||
stationName?: string;
|
||||
stationNumber?: string;
|
||||
}): string {
|
||||
const num = params.stationNumber ? ` (${params.stationNumber})` : '';
|
||||
return `${params.stationName || ''}${num} — 次の発車予定`;
|
||||
}
|
||||
|
||||
function buildStationLockLines(trains: StationTrainInfo[]): string[] {
|
||||
return trains.map((t) => {
|
||||
const parts = [t.time, t.typeName, t.trainName, `→ ${t.destination}`];
|
||||
if (t.platform) parts.push(`(${t.platform})`);
|
||||
if (t.delayStatus && t.delayStatus !== '定刻') parts.push(t.delayStatus);
|
||||
return parts.filter(Boolean).join(' ');
|
||||
});
|
||||
}
|
||||
|
||||
// MARK: - Availability
|
||||
|
||||
// DEBUG: モジュールのロード状態を確認
|
||||
console.log('[ExpoLiveActivity] module loaded:', ExpoLiveActivityModule != null);
|
||||
if (ExpoLiveActivityModule) {
|
||||
console.log('[ExpoLiveActivity] isAvailable():', ExpoLiveActivityModule.isAvailable());
|
||||
}
|
||||
|
||||
/**
|
||||
* このデバイスで Live Activity が使用可能かを返す。
|
||||
* iOS 16.2+ の実機かつユーザーが許可している場合のみ true。
|
||||
* Android では常に true。
|
||||
*/
|
||||
export function isAvailable(): boolean {
|
||||
return ExpoLiveActivityModule?.isAvailable() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Android: ライブ通知 (Foreground Service) がアクティブかを返す。
|
||||
* iOS では常に false を返す。
|
||||
*/
|
||||
export function isLiveNotificationActive(): boolean {
|
||||
if (Platform.OS !== 'android') return false;
|
||||
return ExpoLiveActivityModule?.isLiveNotificationActive() ?? false;
|
||||
}
|
||||
|
||||
// MARK: - 列車追従 Live Activity
|
||||
|
||||
/**
|
||||
* 列車追従 Live Activity を開始する。
|
||||
* @returns Activity ID (更新・終了時に使用)
|
||||
*/
|
||||
export async function startTrainFollowActivity(
|
||||
params: TrainFollowParams
|
||||
): Promise<string> {
|
||||
if (!ExpoLiveActivityModule) throw new Error('ExpoLiveActivity is not available');
|
||||
if (Platform.OS === 'android') {
|
||||
const progressCurrent = params.nextStationIndex != null && params.nextStationIndex >= 0
|
||||
? params.nextStationIndex + 1 : 0;
|
||||
const progressTotal = params.stationStops?.length ?? 0;
|
||||
return await ExpoLiveActivityModule.startTrainFollowNotification(
|
||||
buildTrainFollowTitle(params),
|
||||
buildTrainFollowBody(params),
|
||||
params.trainTypeColor || '#00B8FF',
|
||||
progressCurrent,
|
||||
progressTotal
|
||||
);
|
||||
}
|
||||
return await ExpoLiveActivityModule.startTrainFollowActivity(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列車追従 Live Activity の状態を更新する。
|
||||
*/
|
||||
export async function updateTrainFollowActivity(
|
||||
activityId: string,
|
||||
state: TrainFollowState
|
||||
): Promise<void> {
|
||||
if (!ExpoLiveActivityModule) throw new Error('ExpoLiveActivity is not available');
|
||||
if (Platform.OS === 'android') {
|
||||
const progressCurrent = state.nextStationIndex != null && state.nextStationIndex >= 0
|
||||
? state.nextStationIndex + 1 : 0;
|
||||
const progressTotal = state.stationStops?.length ?? 0;
|
||||
await ExpoLiveActivityModule.updateTrainFollowNotification(
|
||||
buildTrainFollowTitle(state),
|
||||
buildTrainFollowBody(state),
|
||||
state.trainTypeColor || '#00B8FF',
|
||||
progressCurrent,
|
||||
progressTotal
|
||||
);
|
||||
return;
|
||||
}
|
||||
await ExpoLiveActivityModule.updateTrainFollowActivity(activityId, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列車追従 Live Activity を終了する。
|
||||
*/
|
||||
export async function endTrainFollowActivity(activityId: string): Promise<void> {
|
||||
if (!ExpoLiveActivityModule) throw new Error('ExpoLiveActivity is not available');
|
||||
if (Platform.OS === 'android') {
|
||||
await ExpoLiveActivityModule.stopTrainFollowNotification();
|
||||
return;
|
||||
}
|
||||
await ExpoLiveActivityModule.endTrainFollowActivity(activityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 現在アクティブな列車追従 Live Activity の ID 一覧を返す。
|
||||
*/
|
||||
export function getActiveTrainFollowActivities(): string[] {
|
||||
return ExpoLiveActivityModule?.getActiveTrainFollowActivities() ?? [];
|
||||
}
|
||||
|
||||
// MARK: - 駅ロック Live Activity
|
||||
|
||||
/**
|
||||
* 駅ロック Live Activity を開始する。
|
||||
* @returns Activity ID
|
||||
*/
|
||||
export async function startStationLockActivity(
|
||||
params: StationLockParams
|
||||
): Promise<string> {
|
||||
if (!ExpoLiveActivityModule) throw new Error('ExpoLiveActivity is not available');
|
||||
if (Platform.OS === 'android') {
|
||||
const title = buildStationLockTitle(params);
|
||||
const lines = params.trains ? buildStationLockLines(params.trains) : [];
|
||||
const summary =
|
||||
params.trains && params.trains.length > 7
|
||||
? `他${params.trains.length - 7}本`
|
||||
: '';
|
||||
return await ExpoLiveActivityModule.startStationLockNotification(
|
||||
title,
|
||||
JSON.stringify(lines),
|
||||
params.lineColor || '#00B8FF',
|
||||
summary
|
||||
);
|
||||
}
|
||||
return await ExpoLiveActivityModule.startStationLockActivity(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 駅ロック Live Activity の状態を更新する。
|
||||
*/
|
||||
export async function updateStationLockActivity(
|
||||
activityId: string,
|
||||
state: StationLockState
|
||||
): Promise<void> {
|
||||
if (!ExpoLiveActivityModule) throw new Error('ExpoLiveActivity is not available');
|
||||
if (Platform.OS === 'android') {
|
||||
const title = buildStationLockTitle(state);
|
||||
const lines = state.trains ? buildStationLockLines(state.trains) : [];
|
||||
const summary =
|
||||
state.trains && state.trains.length > 7
|
||||
? `他${state.trains.length - 7}本`
|
||||
: '';
|
||||
await ExpoLiveActivityModule.updateStationLockNotification(
|
||||
title,
|
||||
JSON.stringify(lines),
|
||||
state.lineColor || '#00B8FF',
|
||||
summary
|
||||
);
|
||||
return;
|
||||
}
|
||||
await ExpoLiveActivityModule.updateStationLockActivity(activityId, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* 駅ロック Live Activity を終了する。
|
||||
*/
|
||||
export async function endStationLockActivity(activityId: string): Promise<void> {
|
||||
if (!ExpoLiveActivityModule) throw new Error('ExpoLiveActivity is not available');
|
||||
if (Platform.OS === 'android') {
|
||||
await ExpoLiveActivityModule.stopStationLockNotification();
|
||||
return;
|
||||
}
|
||||
await ExpoLiveActivityModule.endStationLockActivity(activityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 現在アクティブな駅ロック Live Activity の ID 一覧を返す。
|
||||
*/
|
||||
export function getActiveStationLockActivities(): string[] {
|
||||
return ExpoLiveActivityModule?.getActiveStationLockActivities() ?? [];
|
||||
}
|
||||
|
||||
// MARK: - Utility
|
||||
|
||||
/**
|
||||
* すべての Live Activity を即座に終了する。
|
||||
*/
|
||||
export async function endAllActivities(): Promise<void> {
|
||||
if (!ExpoLiveActivityModule) return;
|
||||
await ExpoLiveActivityModule.endAllActivities();
|
||||
}
|
||||
+4
-2
@@ -12,10 +12,12 @@ import Constants from "expo-constants";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { useThemeColors } from "@/lib/theme";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
export default function tndView() {
|
||||
const webview = useRef<WebView>(null);
|
||||
const { navigate, addListener, isFocused } = useNavigation();
|
||||
const { fixed } = useThemeColors();
|
||||
const { top } = useSafeAreaInsets();
|
||||
const jsa = `
|
||||
document.querySelector('.sitettl').style.display = 'none';
|
||||
document.querySelector('.attention').style.display = 'none';
|
||||
@@ -131,7 +133,7 @@ setInterval(() => {
|
||||
style={{
|
||||
backgroundColor: fixed.primary,
|
||||
height: "100%",
|
||||
paddingTop: Platform.OS == "ios" ? Constants.statusBarHeight : 0,
|
||||
paddingTop: top,
|
||||
}}
|
||||
>
|
||||
<WebView
|
||||
@@ -149,7 +151,7 @@ setInterval(() => {
|
||||
/>
|
||||
<ReloadButton
|
||||
onPress={() => webview.current.reload()}
|
||||
top={Platform.OS == "ios" ? Constants.statusBarHeight : 0}
|
||||
top={top}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
+32
-24
@@ -27,51 +27,53 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@bacons/apple-targets": "^4.0.6",
|
||||
"@expo/metro-runtime": "~55.0.6",
|
||||
"@expo/ngrok": "^4.1.0",
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@expo/ngrok": "^4.1.3",
|
||||
"@expo/vector-icons": "^15.1.1",
|
||||
"@gorhom/bottom-sheet": "^5",
|
||||
"@lottiefiles/dotlottie-react": "^0.13.0",
|
||||
"@lottiefiles/dotlottie-react": "^0.18.7",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-masked-view/masked-view": "0.3.2",
|
||||
"@react-native-vector-icons/fontawesome": "^12.4.3",
|
||||
"@react-native-vector-icons/material-icons": "^12.4.3",
|
||||
"@react-navigation/bottom-tabs": "^7.2.0",
|
||||
"@react-navigation/native": "^7.0.14",
|
||||
"@react-navigation/stack": "^7.1.1",
|
||||
"@react-navigation/bottom-tabs": "^7.15.6",
|
||||
"@react-navigation/native": "^7.1.34",
|
||||
"@react-navigation/stack": "^7.8.6",
|
||||
"@rneui/base": "5.0.0",
|
||||
"@rneui/themed": "5.0.0",
|
||||
"dayjs": "^1.11.9",
|
||||
"expo": "^55.0.0",
|
||||
"expo-alternate-app-icons": "^1.3.0",
|
||||
"expo-asset": "~55.0.9",
|
||||
"dayjs": "^1.11.20",
|
||||
"expo": "^55.0.8",
|
||||
"expo-alternate-app-icons": "8.0.0",
|
||||
"expo-asset": "~55.0.10",
|
||||
"expo-audio": "~55.0.9",
|
||||
"expo-build-properties": "~55.0.10",
|
||||
"expo-clipboard": "~55.0.9",
|
||||
"expo-constants": "~55.0.8",
|
||||
"expo-dev-client": "~55.0.17",
|
||||
"expo-constants": "~55.0.9",
|
||||
"expo-dev-client": "~55.0.18",
|
||||
"expo-device": "~55.0.10",
|
||||
"expo-felica-reader": "file:./modules/expo-felica-reader",
|
||||
"expo-font": "~55.0.4",
|
||||
"expo-haptics": "~55.0.9",
|
||||
"expo-intent-launcher": "~55.0.9",
|
||||
"expo-keep-awake": "~55.0.4",
|
||||
"expo-linear-gradient": "~55.0.9",
|
||||
"expo-linking": "~55.0.7",
|
||||
"expo-linking": "~55.0.8",
|
||||
"expo-localization": "~55.0.9",
|
||||
"expo-location": "~55.1.3",
|
||||
"expo-location": "~55.1.4",
|
||||
"expo-notifications": "~55.0.13",
|
||||
"expo-screen-orientation": "~55.0.9",
|
||||
"expo-sharing": "~55.0.12",
|
||||
"expo-sharing": "~55.0.14",
|
||||
"expo-status-bar": "~55.0.4",
|
||||
"expo-system-ui": "~55.0.10",
|
||||
"expo-updates": "~55.0.14",
|
||||
"expo-updates": "~55.0.15",
|
||||
"expo-video": "~55.0.11",
|
||||
"expo-web-browser": "~55.0.10",
|
||||
"lottie-react-native": "~7.3.1",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-native": "0.83.2",
|
||||
"react-native-actions-sheet": "^0.9.7",
|
||||
"react-native-actions-sheet": "10.1.2",
|
||||
"react-native-android-widget": "^0.20.1",
|
||||
"react-native-gesture-handler": "~2.30.0",
|
||||
"react-native-maps": "1.27.2",
|
||||
@@ -80,24 +82,30 @@
|
||||
"react-native-responsive-screen": "^1.4.2",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.23.0",
|
||||
"react-native-sortables": "^1.1.0",
|
||||
"react-native-sortables": "1.9.4",
|
||||
"react-native-storage": "^1.0.1",
|
||||
"react-native-svg": "15.15.3",
|
||||
"react-native-touchable-scale": "^2.2.0",
|
||||
"react-native-vector-icons": "^10.2.0",
|
||||
"react-native-vector-icons": "^10.3.0",
|
||||
"react-native-view-shot": "~4.0.3",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-web": "^0.21.2",
|
||||
"react-native-webview": "13.16.0",
|
||||
"react-native-worklets": "0.7.2",
|
||||
"typescript": "~5.9.2"
|
||||
"typescript": "~5.9.3",
|
||||
"expo-live-activity": "file:./modules/expo-live-activity"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.10",
|
||||
"babel-preset-expo": "~55.0.8"
|
||||
"@types/react": "~19.2.14",
|
||||
"babel-preset-expo": "~55.0.12",
|
||||
"baseline-browser-mapping": "^2.10.9"
|
||||
},
|
||||
"resolutions": {
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0"
|
||||
"react-dom": "19.2.0",
|
||||
"expo-asset": "55.0.10",
|
||||
"expo-constants": "55.0.9",
|
||||
"expo-manifests": "55.0.11",
|
||||
"react-native-worklets": "0.7.2"
|
||||
},
|
||||
"private": true,
|
||||
"name": "jrshikoku",
|
||||
|
||||
@@ -35,6 +35,8 @@ const initialState = {
|
||||
getPosition: ((e) => {}) as (e: trainDataType) => string[] | undefined,
|
||||
fixedPositionSize: 80,
|
||||
setFixedPositionSize: (e) => {},
|
||||
liveNotificationActive: false,
|
||||
setLiveNotificationActive: (e) => {},
|
||||
};
|
||||
|
||||
type initialStateType = {
|
||||
@@ -59,6 +61,8 @@ type initialStateType = {
|
||||
getPosition: (e: trainDataType) => string[] | undefined;
|
||||
fixedPositionSize: number;
|
||||
setFixedPositionSize: (e: number) => void;
|
||||
liveNotificationActive: boolean;
|
||||
setLiveNotificationActive: (e: boolean) => void;
|
||||
};
|
||||
|
||||
const CurrentTrainContext = createContext<initialStateType>(initialState);
|
||||
@@ -89,6 +93,7 @@ export const CurrentTrainProvider: FC<props> = ({ children }) => {
|
||||
value: null,
|
||||
});
|
||||
const [fixedPositionSize, setFixedPositionSize] = useState(80);
|
||||
const [liveNotificationActive, setLiveNotificationActive] = useState(false);
|
||||
const [_, setIntervalState] = useInterval(() => {
|
||||
if (!webview.current) return;
|
||||
if (fixedPosition.type == "station") {
|
||||
@@ -141,14 +146,14 @@ export const CurrentTrainProvider: FC<props> = ({ children }) => {
|
||||
} else {
|
||||
inject(`setReload()`);
|
||||
}
|
||||
}, 15000);
|
||||
// useEffect(() => {
|
||||
// if (fixedPosition) {
|
||||
// setIntervalState.start();
|
||||
// } else {
|
||||
// setIntervalState.stop();
|
||||
// }
|
||||
// }, [fixedPosition]);
|
||||
}, 15000, false, liveNotificationActive);
|
||||
useEffect(() => {
|
||||
if (fixedPosition?.type) {
|
||||
setIntervalState.start();
|
||||
} else {
|
||||
setIntervalState.stop();
|
||||
}
|
||||
}, [fixedPosition]);
|
||||
|
||||
type getPositionFuncType = (
|
||||
currentTrainData: trainDataType
|
||||
@@ -313,7 +318,7 @@ export const CurrentTrainProvider: FC<props> = ({ children }) => {
|
||||
|
||||
const [_0, _1] = useInterval(() => {
|
||||
getCurrentTrain();
|
||||
}, 10000); //10秒毎に全在線列車取得
|
||||
}, 10000, true, liveNotificationActive); //10秒毎に全在線列車取得
|
||||
|
||||
return (
|
||||
<CurrentTrainContext.Provider
|
||||
@@ -333,6 +338,8 @@ export const CurrentTrainProvider: FC<props> = ({ children }) => {
|
||||
nearestStationID,
|
||||
fixedPositionSize,
|
||||
setFixedPositionSize,
|
||||
liveNotificationActive,
|
||||
setLiveNotificationActive,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||
import { useWindowDimensions } from "react-native";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
const initialState = { isLandscape: false, setIsLandscape: (e) => {} };
|
||||
|
||||
const DeviceOrientationChange = createContext(initialState);
|
||||
@@ -12,15 +11,6 @@ export const useDeviceOrientationChange = () => {
|
||||
export const DeviceOrientationChangeProvider = ({ children }) => {
|
||||
const [isLandscape, setIsLandscape] = useState(false);
|
||||
const { height, width } = useWindowDimensions();
|
||||
const data = async () => {
|
||||
await ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||
);
|
||||
};
|
||||
useEffect(() => {
|
||||
//data();
|
||||
ScreenOrientation.unlockAsync();
|
||||
}, []);
|
||||
|
||||
// useEffect(() => {
|
||||
// if (height / width > 1.5) {
|
||||
|
||||
@@ -59,6 +59,12 @@ async function registerForPushNotificationsAsync() {
|
||||
vibrationPattern: [0, 250, 250, 250],
|
||||
lightColor: "#FF231F7C",
|
||||
});
|
||||
Notifications.setNotificationChannelAsync("live_tracking", {
|
||||
name: "列車追従・駅ロック",
|
||||
importance: Notifications.AndroidImportance.LOW,
|
||||
vibrationPattern: [0, 0, 0, 0],
|
||||
lightColor: "#00B8FF",
|
||||
});
|
||||
}
|
||||
|
||||
if (Device.isDevice) {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Delay Info Widget (遅延速報EX)
|
||||
|
||||
struct DelayEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let items: [DelayItem]
|
||||
let isLoading: Bool
|
||||
}
|
||||
|
||||
struct DelayItem: Identifiable {
|
||||
let id = UUID()
|
||||
let line: String
|
||||
let minutes: String
|
||||
let direction: String
|
||||
}
|
||||
|
||||
struct DelayInfoProvider: TimelineProvider {
|
||||
private let endpoint = "https://script.google.com/macros/s/AKfycbw-0RDLAu8EQAEWA860tk4KVW6VOr3iIU900AcWEfqIP16gtNUG1XO_A3oBfAGiNeCf/exec"
|
||||
|
||||
func placeholder(in context: Context) -> DelayEntry {
|
||||
DelayEntry(date: Date(), items: [], isLoading: true)
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (DelayEntry) -> Void) {
|
||||
if context.isPreview {
|
||||
completion(DelayEntry(date: Date(), items: [
|
||||
DelayItem(line: "予讃線", minutes: "5分", direction: "上り")
|
||||
], isLoading: false))
|
||||
return
|
||||
}
|
||||
fetchData(completion: completion)
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<DelayEntry>) -> Void) {
|
||||
fetchData { entry in
|
||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date())!
|
||||
completion(Timeline(entries: [entry], policy: .after(next)))
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchData(completion: @escaping (DelayEntry) -> Void) {
|
||||
guard let url = URL(string: endpoint) else {
|
||||
completion(DelayEntry(date: Date(), items: [], isLoading: false))
|
||||
return
|
||||
}
|
||||
URLSession.shared.dataTask(with: url) { data, _, error in
|
||||
guard let data = data, error == nil,
|
||||
let text = String(data: data, encoding: .utf8),
|
||||
!text.isEmpty else {
|
||||
completion(DelayEntry(date: Date(), items: [], isLoading: false))
|
||||
return
|
||||
}
|
||||
let parts = text.components(separatedBy: "^")
|
||||
let items = parts.compactMap { part -> DelayItem? in
|
||||
let fields = part.replacingOccurrences(of: "\n", with: "")
|
||||
.components(separatedBy: " ")
|
||||
.filter { !$0.isEmpty }
|
||||
guard fields.count >= 3 else { return nil }
|
||||
return DelayItem(
|
||||
line: fields[0],
|
||||
minutes: fields[1],
|
||||
direction: fields.count > 3 ? fields[3] : fields[2]
|
||||
)
|
||||
}
|
||||
completion(DelayEntry(date: Date(), items: items, isLoading: false))
|
||||
}.resume()
|
||||
}
|
||||
}
|
||||
|
||||
struct DelayInfoWidgetView: View {
|
||||
var entry: DelayEntry
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack {
|
||||
Text("列車遅延速報EX")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.white)
|
||||
Spacer()
|
||||
Text(entry.date, style: .time)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(red: 0, green: 0.6, blue: 0.8))
|
||||
|
||||
// Body
|
||||
if entry.isLoading {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
Spacer()
|
||||
} else if entry.items.isEmpty {
|
||||
Spacer()
|
||||
Text("現在、5分以上の遅れはありません。")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.primary)
|
||||
.padding()
|
||||
Spacer()
|
||||
} else {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(entry.items) { item in
|
||||
HStack {
|
||||
Text(item.line)
|
||||
.font(.subheadline)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(item.minutes)
|
||||
.font(.subheadline)
|
||||
.frame(width: 50)
|
||||
Text(item.direction)
|
||||
.font(.subheadline)
|
||||
.frame(width: 40)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
.containerBackground(for: .widget) {
|
||||
Color(.systemBackground)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DelayInfoWidget: Widget {
|
||||
let kind = "DelayInfoWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: DelayInfoProvider()) { entry in
|
||||
DelayInfoWidgetView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("列車遅延速報EX")
|
||||
.description("JR四国の列車遅延情報を表示")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Felica Balance Widget
|
||||
|
||||
struct BalanceEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let balance: String
|
||||
let detail: String
|
||||
}
|
||||
|
||||
struct BalanceProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> BalanceEntry {
|
||||
BalanceEntry(date: Date(), balance: "¥---", detail: "カードをタップして読取開始")
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (BalanceEntry) -> Void) {
|
||||
completion(makeEntry())
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<BalanceEntry>) -> Void) {
|
||||
let entry = makeEntry()
|
||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date())!
|
||||
completion(Timeline(entries: [entry], policy: .after(next)))
|
||||
}
|
||||
|
||||
private func makeEntry() -> BalanceEntry {
|
||||
if let snapshot = loadFelicaSnapshot() {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
let formatted = formatter.string(from: NSNumber(value: snapshot.balance)) ?? "\(snapshot.balance)"
|
||||
return BalanceEntry(
|
||||
date: Date(),
|
||||
balance: "¥\(formatted)",
|
||||
detail: "最終読取: \(snapshot.scannedAt)"
|
||||
)
|
||||
}
|
||||
return BalanceEntry(date: Date(), balance: "未読取", detail: "カードをタップして読取開始")
|
||||
}
|
||||
}
|
||||
|
||||
struct FelicaBalanceWidgetView: View {
|
||||
var entry: BalanceEntry
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text("ICカード残高")
|
||||
.font(.caption)
|
||||
.foregroundColor(Color(red: 0.65, green: 0.74, blue: 0.83))
|
||||
Spacer()
|
||||
Text(entry.date, style: .time)
|
||||
.font(.caption2)
|
||||
.foregroundColor(Color(red: 0.83, green: 0.90, blue: 1.0))
|
||||
}
|
||||
|
||||
Text(entry.balance)
|
||||
.font(.system(size: 34, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.minimumScaleFactor(0.5)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(entry.detail)
|
||||
.font(.caption2)
|
||||
.foregroundColor(Color(red: 0.61, green: 0.71, blue: 0.80))
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("タップでFelicaスキャン")
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(Color(red: 0.48, green: 0.85, blue: 0.76))
|
||||
}
|
||||
.padding(14)
|
||||
.widgetURL(URL(string: "jrshikoku://open/felica"))
|
||||
.containerBackground(for: .widget) {
|
||||
Color(red: 0.043, green: 0.114, blue: 0.165)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FelicaBalanceWidget: Widget {
|
||||
let kind = "FelicaBalanceWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: BalanceProvider()) { entry in
|
||||
FelicaBalanceWidgetView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("ICカード残高")
|
||||
.description("Felica対応ICカードの残高を表示")
|
||||
.supportedFamilies([.systemSmall, .systemMedium])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>JR四国ウィジェット</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>7.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.jrshikokuinfo.xprocess.hrkn</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,15 @@
|
||||
import ActivityKit
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
@main
|
||||
struct JRWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
ShortcutWidget()
|
||||
FelicaBalanceWidget()
|
||||
DelayInfoWidget()
|
||||
OperationInfoWidget()
|
||||
TrainFollowLiveActivity()
|
||||
StationLockLiveActivity()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Operation Info Widget (運行情報)
|
||||
|
||||
struct OperationEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let text: String
|
||||
let isLoading: Bool
|
||||
}
|
||||
|
||||
struct OperationInfoProvider: TimelineProvider {
|
||||
private let endpoint = "https://script.google.com/macros/s/AKfycbz80LcaEUrhnlEsLkJy0LG2IRO3DBVQhfNmN1d_0f_HvtsujNQpxM90SrV9yKWH_JG1Ww/exec"
|
||||
|
||||
func placeholder(in context: Context) -> OperationEntry {
|
||||
OperationEntry(date: Date(), text: "読み込み中…", isLoading: true)
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (OperationEntry) -> Void) {
|
||||
if context.isPreview {
|
||||
completion(OperationEntry(date: Date(), text: "通常運行中です。", isLoading: false))
|
||||
return
|
||||
}
|
||||
fetchData(completion: completion)
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<OperationEntry>) -> Void) {
|
||||
fetchData { entry in
|
||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date())!
|
||||
completion(Timeline(entries: [entry], policy: .after(next)))
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchData(completion: @escaping (OperationEntry) -> Void) {
|
||||
guard let url = URL(string: endpoint) else {
|
||||
completion(OperationEntry(date: Date(), text: "通常運行中です。", isLoading: false))
|
||||
return
|
||||
}
|
||||
URLSession.shared.dataTask(with: url) { data, _, error in
|
||||
guard let data = data, error == nil,
|
||||
let text = String(data: data, encoding: .utf8),
|
||||
!text.isEmpty else {
|
||||
completion(OperationEntry(date: Date(), text: "通常運行中です。", isLoading: false))
|
||||
return
|
||||
}
|
||||
let displayText = text.replacingOccurrences(of: "^", with: "\n")
|
||||
completion(OperationEntry(date: Date(), text: displayText, isLoading: false))
|
||||
}.resume()
|
||||
}
|
||||
}
|
||||
|
||||
struct OperationInfoWidgetView: View {
|
||||
var entry: OperationEntry
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack {
|
||||
Text("列車運行情報")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.white)
|
||||
Spacer()
|
||||
Text(entry.date, style: .time)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(red: 0, green: 0.6, blue: 0.8))
|
||||
|
||||
// Body
|
||||
if entry.isLoading {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
Spacer()
|
||||
} else {
|
||||
ScrollView {
|
||||
Text(entry.text)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.primary)
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
.containerBackground(for: .widget) {
|
||||
Color(.systemBackground)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OperationInfoWidget: Widget {
|
||||
let kind = "OperationInfoWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: OperationInfoProvider()) { entry in
|
||||
OperationInfoWidgetView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("列車運行情報")
|
||||
.description("JR四国の列車運行情報を表示")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
/// App Group ID shared between the main app and widget extension.
|
||||
let appGroupID = "group.jrshikokuinfo.xprocess.hrkn"
|
||||
|
||||
// MARK: - Shared data helpers
|
||||
|
||||
struct FelicaSnapshot: Codable {
|
||||
let balance: Int
|
||||
let idm: String
|
||||
let systemCode: String?
|
||||
let scannedAt: String
|
||||
}
|
||||
|
||||
func sharedDefaults() -> UserDefaults {
|
||||
UserDefaults(suiteName: appGroupID) ?? .standard
|
||||
}
|
||||
|
||||
func loadFelicaSnapshot() -> FelicaSnapshot? {
|
||||
guard let data = sharedDefaults().data(forKey: "felicaLastSnapshot") else { return nil }
|
||||
return try? JSONDecoder().decode(FelicaSnapshot.self, from: data)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Shortcut Widget
|
||||
|
||||
struct ShortcutEntry: TimelineEntry {
|
||||
let date: Date
|
||||
}
|
||||
|
||||
struct ShortcutProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> ShortcutEntry {
|
||||
ShortcutEntry(date: Date())
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (ShortcutEntry) -> Void) {
|
||||
completion(ShortcutEntry(date: Date()))
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<ShortcutEntry>) -> Void) {
|
||||
let entry = ShortcutEntry(date: Date())
|
||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date())!
|
||||
completion(Timeline(entries: [entry], policy: .after(next)))
|
||||
}
|
||||
}
|
||||
|
||||
private struct ShortcutItem {
|
||||
let label: String
|
||||
let icon: String
|
||||
let deeplink: String
|
||||
}
|
||||
|
||||
private let shortcuts: [ShortcutItem] = [
|
||||
ShortcutItem(label: "列車位置", icon: "tram.fill", deeplink: "jrshikoku://open/traininfo"),
|
||||
ShortcutItem(label: "運行情報", icon: "doc.text.fill", deeplink: "jrshikoku://open/operation"),
|
||||
ShortcutItem(label: "IC読取", icon: "creditcard.fill", deeplink: "jrshikoku://open/felica"),
|
||||
ShortcutItem(label: "設定", icon: "gearshape.fill", deeplink: "jrshikoku://open/settings"),
|
||||
]
|
||||
|
||||
struct ShortcutWidgetView: View {
|
||||
var entry: ShortcutEntry
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
HStack {
|
||||
Text("JR四国 ショートカット")
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(Color(red: 0.56, green: 0.77, blue: 1.0))
|
||||
Spacer()
|
||||
Text(entry.date, style: .time)
|
||||
.font(.caption2)
|
||||
.foregroundColor(Color(red: 0.83, green: 0.90, blue: 1.0))
|
||||
}
|
||||
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
|
||||
ForEach(shortcuts, id: \.label) { item in
|
||||
Link(destination: URL(string: item.deeplink)!) {
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: item.icon)
|
||||
.font(.title2)
|
||||
Text(item.label)
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(8)
|
||||
.background(Color(red: 0.075, green: 0.184, blue: 0.275))
|
||||
.cornerRadius(10)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.containerBackground(for: .widget) {
|
||||
Color(red: 0.043, green: 0.114, blue: 0.165)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ShortcutWidget: Widget {
|
||||
let kind = "ShortcutWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: ShortcutProvider()) { entry in
|
||||
ShortcutWidgetView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("クイックアクセス")
|
||||
.description("アプリの各機能にすばやくアクセス")
|
||||
.supportedFamilies([.systemSmall, .systemMedium])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import ActivityKit
|
||||
import Foundation
|
||||
|
||||
// MARK: - 駅ロック Live Activity Attributes
|
||||
|
||||
struct StationLockAttributes: ActivityAttributes {
|
||||
public struct ContentState: Codable, Hashable {
|
||||
/// 次の列車の発車時刻 ("HH:mm" 形式)
|
||||
var nextTrainTime: String
|
||||
/// 次の列車の行き先
|
||||
var nextTrainDestination: String
|
||||
/// 次の列車のホーム番線 (例: "1番線")
|
||||
var nextTrainPlatform: String
|
||||
/// その次の列車の発車時刻 ("HH:mm" 形式、なければ空文字)
|
||||
var followingTrainTime: String
|
||||
/// その次の列車の行き先
|
||||
var followingTrainDestination: String
|
||||
/// 最終更新日時
|
||||
var updatedAt: Date
|
||||
}
|
||||
|
||||
/// 駅名
|
||||
var stationName: String
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import ActivityKit
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
// MARK: - 駅ロック Live Activity
|
||||
|
||||
// MARK: Lock screen / notification banner
|
||||
|
||||
private struct StationLockBannerView: View {
|
||||
let context: ActivityViewContext<StationLockAttributes>
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack {
|
||||
Image(systemName: "building.2.fill")
|
||||
.foregroundColor(.white)
|
||||
Text(context.attributes.stationName)
|
||||
.font(.subheadline).fontWeight(.bold)
|
||||
.foregroundColor(.white)
|
||||
Spacer()
|
||||
Text("発車情報")
|
||||
.font(.caption)
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(red: 0, green: 0.6, blue: 0.8))
|
||||
|
||||
// Train rows
|
||||
VStack(spacing: 0) {
|
||||
TrainRowView(
|
||||
label: "次の列車",
|
||||
time: context.state.nextTrainTime,
|
||||
destination: context.state.nextTrainDestination,
|
||||
platform: context.state.nextTrainPlatform,
|
||||
isNext: true
|
||||
)
|
||||
|
||||
if !context.state.followingTrainTime.isEmpty {
|
||||
Divider().padding(.leading, 14)
|
||||
|
||||
TrainRowView(
|
||||
label: "その次",
|
||||
time: context.state.followingTrainTime,
|
||||
destination: context.state.followingTrainDestination,
|
||||
platform: "",
|
||||
isNext: false
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TrainRowView: View {
|
||||
let label: String
|
||||
let time: String
|
||||
let destination: String
|
||||
let platform: String
|
||||
let isNext: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 44, alignment: .leading)
|
||||
|
||||
Text(time)
|
||||
.font(isNext ? .headline : .subheadline)
|
||||
.fontWeight(isNext ? .bold : .regular)
|
||||
.foregroundColor(isNext ? Color(red: 0, green: 0.6, blue: 0.8) : .primary)
|
||||
.monospacedDigit()
|
||||
|
||||
Text(destination)
|
||||
.font(isNext ? .subheadline : .caption)
|
||||
.fontWeight(isNext ? .semibold : .regular)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
|
||||
Spacer()
|
||||
|
||||
if !platform.isEmpty {
|
||||
Text(platform)
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color(red: 0, green: 0.6, blue: 0.8).opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
.foregroundColor(Color(red: 0, green: 0.6, blue: 0.8))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Widget
|
||||
|
||||
struct StationLockLiveActivity: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: StationLockAttributes.self) { context in
|
||||
StationLockBannerView(context: context)
|
||||
.activityBackgroundTint(Color(.systemBackground))
|
||||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("次の列車")
|
||||
.font(.caption2).foregroundColor(.secondary)
|
||||
Text(context.state.nextTrainTime)
|
||||
.font(.title3).fontWeight(.bold).monospacedDigit()
|
||||
.foregroundColor(Color(red: 0, green: 0.6, blue: 0.8))
|
||||
}
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(context.state.nextTrainDestination)
|
||||
.font(.subheadline).fontWeight(.semibold).lineLimit(1)
|
||||
if !context.state.nextTrainPlatform.isEmpty {
|
||||
Text(context.state.nextTrainPlatform)
|
||||
.font(.caption2).foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
if !context.state.followingTrainTime.isEmpty {
|
||||
HStack {
|
||||
Text("その次: \(context.state.followingTrainTime)")
|
||||
.font(.caption2).foregroundColor(.secondary)
|
||||
Text(context.state.followingTrainDestination)
|
||||
.font(.caption2).foregroundColor(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
}
|
||||
DynamicIslandExpandedRegion(.center) {
|
||||
Label(context.attributes.stationName, systemImage: "building.2.fill")
|
||||
.font(.caption).foregroundColor(.secondary)
|
||||
}
|
||||
} compactLeading: {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: "clock.fill")
|
||||
.font(.caption2)
|
||||
.foregroundColor(Color(red: 0, green: 0.6, blue: 0.8))
|
||||
Text(context.state.nextTrainTime)
|
||||
.font(.caption2).fontWeight(.bold).monospacedDigit()
|
||||
}
|
||||
} compactTrailing: {
|
||||
Text(context.state.nextTrainDestination)
|
||||
.font(.caption2).lineLimit(1)
|
||||
} minimal: {
|
||||
Image(systemName: "building.2.fill")
|
||||
.foregroundColor(Color(red: 0, green: 0.6, blue: 0.8))
|
||||
}
|
||||
.keylineTint(Color(red: 0, green: 0.6, blue: 0.8))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import ActivityKit
|
||||
import Foundation
|
||||
|
||||
// MARK: - 列車追従 Live Activity Attributes
|
||||
|
||||
struct TrainFollowAttributes: ActivityAttributes {
|
||||
public struct ContentState: Codable, Hashable {
|
||||
/// 現在停車中または直前に通過した駅名
|
||||
var currentStation: String
|
||||
/// 次の停車駅名
|
||||
var nextStation: String
|
||||
/// 遅延分数 (0 = 定刻)
|
||||
var delayMinutes: Int
|
||||
/// 終着駅の到着予定時刻 ("HH:mm" 形式)
|
||||
var scheduledArrival: String
|
||||
/// 最終更新日時
|
||||
var updatedAt: Date
|
||||
}
|
||||
|
||||
/// 列車番号 (例: "2001D")
|
||||
var trainNumber: String
|
||||
/// 路線名 (例: "土讃線")
|
||||
var lineName: String
|
||||
/// 行き先 (例: "高知")
|
||||
var destination: String
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import ActivityKit
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
// MARK: - 列車追従 Live Activity
|
||||
|
||||
// MARK: Lock screen / notification banner
|
||||
|
||||
private struct TrainFollowBannerView: View {
|
||||
let context: ActivityViewContext<TrainFollowAttributes>
|
||||
|
||||
private var delayColor: Color {
|
||||
context.state.delayMinutes > 0 ? .red : Color(red: 0, green: 0.72, blue: 0.42)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack {
|
||||
Image(systemName: "tram.fill")
|
||||
.foregroundColor(.white)
|
||||
Text("\(context.attributes.lineName) \(context.attributes.trainNumber)")
|
||||
.font(.subheadline).fontWeight(.bold)
|
||||
.foregroundColor(.white)
|
||||
Spacer()
|
||||
Text(context.attributes.destination + "行き")
|
||||
.font(.caption)
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(red: 0, green: 0.6, blue: 0.8))
|
||||
|
||||
// Body
|
||||
HStack(spacing: 0) {
|
||||
// Current station
|
||||
VStack(spacing: 2) {
|
||||
Text("現在")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Text(context.state.currentStation)
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Image(systemName: "chevron.right.2")
|
||||
.foregroundColor(Color(red: 0, green: 0.6, blue: 0.8))
|
||||
.font(.title3)
|
||||
|
||||
// Next station
|
||||
VStack(spacing: 2) {
|
||||
Text("次の停車駅")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
Text(context.state.nextStation)
|
||||
.font(.headline)
|
||||
.fontWeight(.bold)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
.foregroundColor(Color(red: 0, green: 0.6, blue: 0.8))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Divider()
|
||||
.padding(.vertical, 4)
|
||||
|
||||
// Delay + arrival
|
||||
VStack(spacing: 2) {
|
||||
if context.state.delayMinutes > 0 {
|
||||
Text("\(context.state.delayMinutes)分遅れ")
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(delayColor)
|
||||
} else {
|
||||
Text("定刻")
|
||||
.font(.caption)
|
||||
.foregroundColor(delayColor)
|
||||
}
|
||||
Text("着 \(context.state.scheduledArrival)")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Dynamic Island — expanded
|
||||
|
||||
private struct TrainFollowExpandedView: View {
|
||||
let context: ActivityViewContext<TrainFollowAttributes>
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
// Top row: line + train number
|
||||
HStack {
|
||||
Label(context.attributes.lineName, systemImage: "tram.fill")
|
||||
.font(.caption)
|
||||
.foregroundColor(Color(red: 0, green: 0.6, blue: 0.8))
|
||||
Spacer()
|
||||
Text(context.attributes.trainNumber)
|
||||
.font(.caption).fontWeight(.semibold)
|
||||
Text("→")
|
||||
Text(context.attributes.destination)
|
||||
.font(.caption).fontWeight(.semibold)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Station row
|
||||
HStack {
|
||||
Text(context.state.currentStation)
|
||||
.font(.subheadline)
|
||||
Image(systemName: "arrow.right")
|
||||
.foregroundColor(Color(red: 0, green: 0.6, blue: 0.8))
|
||||
Text(context.state.nextStation)
|
||||
.font(.subheadline).fontWeight(.bold)
|
||||
.foregroundColor(Color(red: 0, green: 0.6, blue: 0.8))
|
||||
Spacer()
|
||||
if context.state.delayMinutes > 0 {
|
||||
Text("\(context.state.delayMinutes)分遅れ")
|
||||
.font(.caption).fontWeight(.bold).foregroundColor(.red)
|
||||
} else {
|
||||
Text("定刻")
|
||||
.font(.caption).foregroundColor(.green)
|
||||
}
|
||||
Text("着 \(context.state.scheduledArrival)")
|
||||
.font(.caption).foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Widget
|
||||
|
||||
struct TrainFollowLiveActivity: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: TrainFollowAttributes.self) { context in
|
||||
TrainFollowBannerView(context: context)
|
||||
.activityBackgroundTint(Color(.systemBackground))
|
||||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
Label(context.attributes.trainNumber, systemImage: "tram.fill")
|
||||
.font(.caption).fontWeight(.semibold)
|
||||
.foregroundColor(Color(red: 0, green: 0.6, blue: 0.8))
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
if context.state.delayMinutes > 0 {
|
||||
Text("\(context.state.delayMinutes)分遅れ")
|
||||
.font(.caption).fontWeight(.bold).foregroundColor(.red)
|
||||
} else {
|
||||
Text("定刻")
|
||||
.font(.caption).foregroundColor(.green)
|
||||
}
|
||||
}
|
||||
DynamicIslandExpandedRegion(.center) {
|
||||
Text("\(context.state.currentStation) → \(context.state.nextStation)")
|
||||
.font(.caption)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
HStack {
|
||||
Text(context.attributes.lineName)
|
||||
.font(.caption2).foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Text(context.attributes.destination + " 着 \(context.state.scheduledArrival)")
|
||||
.font(.caption2).foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
} compactLeading: {
|
||||
Label(context.attributes.trainNumber, systemImage: "tram.fill")
|
||||
.font(.caption2).fontWeight(.semibold)
|
||||
.foregroundColor(Color(red: 0, green: 0.6, blue: 0.8))
|
||||
} compactTrailing: {
|
||||
Text(context.state.nextStation)
|
||||
.font(.caption2).lineLimit(1)
|
||||
} minimal: {
|
||||
Image(systemName: "tram.fill")
|
||||
.foregroundColor(Color(red: 0, green: 0.6, blue: 0.8))
|
||||
}
|
||||
.keylineTint(Color(red: 0, green: 0.6, blue: 0.8))
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user