Compare commits

...
27 Commits
Author SHA1 Message Date
harukin-expo-dev-env deb24caaa2 feat: add station progress tracking and notification updates for train follow feature 2026-03-22 17:09:19 +00:00
harukinMBP 5515f42415 feat: add Live Activity button for train tracking and update iOS deployment target 2026-03-23 02:07:24 +09:00
harukin-expo-dev-env fbfb83fa34 feat: add live activity notifications for train tracking and station locking
- Implemented ExpoLiveActivity module for Android to manage live notifications.
- Added foreground service for train tracking and station locking notifications.
- Updated app permissions to include POST_NOTIFICATIONS.
- Enhanced FixedStation and FixedTrain components to support live notifications.
- Introduced new notification builders for train and station activities.
- Updated useCurrentTrain and useNotifications hooks to manage live notification state.
- Added notification channel for live tracking in Android.
2026-03-22 16:15:48 +00:00
harukinMBP 8eb49f57d6 fix: update activity state handling to use ActivityContent with nil staleDate 2026-03-22 23:09:13 +09:00
harukinMBP c5d4dc3b65 feat: add automatic Live Activity updates for train position and station lock in TrainDataView and StationDiagramView 2026-03-22 23:03:07 +09:00
harukinMBP 7f2480bc01 feat: add Live Activity support for station locking in StationDiagramView 2026-03-22 23:01:00 +09:00
harukinMBP b8372e5087 feat: add hooks for managing Live Activities for station locking and train following 2026-03-22 22:51:58 +09:00
harukinMBP 75c07f013d feat: add Live Activities support for train tracking and station locking 2026-03-22 22:49:05 +09:00
harukin-expo-dev-env 46bfea4e13 feat: add androidGoogleMapsApiKey to app.json configuration 2026-03-22 13:17:53 +00:00
harukin-expo-dev-env 5a2dc8c6a8 feat: add appleTeamId to iOS configuration in app.json 2026-03-22 11:48:59 +00:00
harukin-expo-dev-env 06650d014a feat(widget): add Shortcut, Delay Info, and Felica Balance widgets
- Implemented ShortcutWidget for quick access to app features with customizable shortcuts.
- Added DelayInfoWidget to display train delay information fetched from a remote endpoint.
- Created FelicaBalanceWidget to show the balance of Felica-compatible IC cards.
- Introduced OperationInfoWidget for displaying train operation status.
- Set up shared data handling for Felica snapshots between the main app and widget.
- Configured widget assets and entitlements for proper functionality.
- Updated Info.plist and expo-target.config.js for widget deployment.
2026-03-22 11:45:58 +00:00
harukin-expo-dev-env 45feeece58 feat: FeliCa対応の可用性チェック機能を追加 2026-03-22 10:13:36 +00:00
harukinMBP 385c2d8b86 Update app.json build number to 58 and refine NFC error handling in ExpoFelicaReaderModule 2026-03-22 18:56:13 +09:00
harukinMBP 41cc70086a Implement NFC scanning functionality in ExpoFelicaReader module 2026-03-21 22:04:55 +09:00
harukinMBP 98c71127aa Fix .gitignore: re-include modules/**/ios/ for EAS build, remove debug code 2026-03-21 20:13:34 +09:00
harukinMBP d049d3b07d Debug: patch resolveCommand.js and apple.js with logging, add pod fallback 2026-03-21 20:03:40 +09:00
harukinMBP 8484f15092 Enhanced autolinking debug: use external JS file 2026-03-21 19:46:25 +09:00
harukinMBP 1afa5e4377 Add Podfile debug plugin to trace autolinking resolve 2026-03-21 19:33:22 +09:00
harukinMBP 9f6d86b8b6 Fix ExpoFelicaReader module: restore full definition, update diagnostics 2026-03-21 17:54:54 +09:00
harukinMBP 72e7d63bd7 Add expo-felica-reader to dependencies for EAS build autolinking 2026-03-21 17:54:39 +09:00
harukin-expo-dev-env b243439e78 Refactor code structure for improved readability and maintainability 2026-03-20 07:14:58 +00:00
harukin-expo-dev-envandCopilot 65f3b2a877 fix: ダークモード - 設定リスト・お気に入り並び替え・SNSリストの表示修正
- SettingTopPage: SettingList の containerStyle を直接設定して背景色を適用
- FavoiliteSettingsItem: 駅名テキストと並び替えアイコンに color: colors.text を追加
- SocialMenu: SNS一覧の ListItem に containerStyle と ListItem.Title の色を追加

Co-authored-by: Copilot <[email protected]>
2026-03-19 08:46:14 +00:00
harukin-expo-dev-envandCopilot 925d162f26 feat: iPad サポートを有効化
app.json の supportsTablet を true に変更

Co-authored-by: Copilot <[email protected]>
2026-03-18 08:44:36 +00:00
harukin-expo-dev-envandCopilot 731fe504c6 perf: dev client で expo-updates をスキップ
__DEV__ 時は checkForUpdateAsync() が不要なため早期リターン

Co-authored-by: Copilot <[email protected]>
2026-03-18 08:38:10 +00:00
harukin-expo-dev-envandCopilot 942ec395f1 perf: metro transformer に experimentalImportSupport を復元
lazyImports: true と組み合わせて動作するオプション。
resolver への誤設定は除外し transformer のみに適用。

Co-authored-by: Copilot <[email protected]>
2026-03-18 07:42:55 +00:00
harukin-expo-dev-env 306cf6882e chore: 不要なビルドファイル build-1773815539430.apk を削除 2026-03-18 07:12:45 +00:00
harukin-expo-dev-envandCopilot 468bb4633a chore: metro.config.js から deprecated な experimentalImportSupport を削除
SDK 55 の Metro では不要。resolver への設定は誤り。

Co-authored-by: Copilot <[email protected]>
2026-03-18 07:11:15 +00:00
102 changed files with 4300 additions and 952 deletions
+2 -1
View File
@@ -56,4 +56,5 @@ Thumbs.db
android/
!modules/**/android/
ios/
!modules/**/ios/build-*.apk
!modules/**/ios/
*.ipa
+2
View File
@@ -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 />
+47 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+10 -6
View File
@@ -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={{
+1
View File
@@ -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;
+34 -6
View File
@@ -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>
+7 -9
View File
@@ -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",
},
});
-7
View File
@@ -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
View File
@@ -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>
);
}
+63 -5
View File
@@ -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>
+2 -3
View File
@@ -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",
+2 -1
View File
@@ -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 -3
View File
@@ -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",
+24 -31
View File
@@ -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>
);
};
+23 -5
View File
@@ -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>
);
};
+7 -7
View File
@@ -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,
}}
+34 -16
View File
@@ -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]),
+3 -4
View File
@@ -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 */
+12 -3
View File
@@ -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}
+4 -4
View File
@@ -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>
+17 -3
View File
@@ -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,
+3 -4
View File
@@ -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",
}}
+4 -3
View File
@@ -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
+10 -10
View File
@@ -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>
);
+13 -5
View File
@@ -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 }]}>
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
+8 -3
View File
@@ -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>
+88 -167
View File
@@ -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>
);
+17 -14
View File
@@ -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(() => {
+2 -2
View File
@@ -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,
+6 -6
View File
@@ -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 -3
View File
@@ -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",
+4 -5
View File
@@ -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",
}}
+6 -6
View File
@@ -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>
+3 -4
View File
@@ -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 -4
View File
@@ -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 ? {
+7 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+104
View File
@@ -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 };
}
+104
View File
@@ -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 };
}
+3 -1
View File
@@ -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 />
-5
View File
@@ -7,9 +7,4 @@ config.transformer = {
experimentalImportSupport: true,
};
config.resolver = {
...config.resolver,
experimentalImportSupport: true,
};
module.exports = config;
@@ -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');
+41
View File
@@ -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>
@@ -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>()
}
}
}
}
@@ -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()
}
}
}
@@ -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()
}
}
@@ -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)
}
}
}
}
+10
View File
@@ -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');
+369
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+16 -9
View File
@@ -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}
-10
View File
@@ -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) {
+6
View File
@@ -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
}
}
+144
View File
@@ -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])
}
}
+93
View File
@@ -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])
}
}
+31
View File
@@ -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>
+10
View File
@@ -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>
+15
View File
@@ -0,0 +1,15 @@
import ActivityKit
import SwiftUI
import WidgetKit
@main
struct JRWidgetBundle: WidgetBundle {
var body: some Widget {
ShortcutWidget()
FelicaBalanceWidget()
DelayInfoWidget()
OperationInfoWidget()
TrainFollowLiveActivity()
StationLockLiveActivity()
}
}
+104
View File
@@ -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])
}
}
+23
View File
@@ -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)
}
+92
View File
@@ -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