529 lines
19 KiB
TypeScript
529 lines
19 KiB
TypeScript
import React, { useRef, useState } from "react";
|
||
import {
|
||
Alert,
|
||
ScrollView,
|
||
StyleSheet,
|
||
Text,
|
||
TouchableOpacity,
|
||
View,
|
||
} from "react-native";
|
||
import { Switch } from "@rneui/themed";
|
||
import { useNavigation } from "@react-navigation/native";
|
||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||
import Swipeable from "react-native-gesture-handler/Swipeable";
|
||
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
|
||
import { useThemeColors } from "@/lib/theme";
|
||
import { useTrainMenu } from "@/stateBox/useTrainMenu";
|
||
import { useNotification } from "@/stateBox/useNotifications";
|
||
|
||
export const ResearchToolsSettings = () => {
|
||
const navigation = useNavigation<any>();
|
||
const { colors, fixed } = useThemeColors();
|
||
const { expoPushToken } = useNotification();
|
||
const {
|
||
updatePermission,
|
||
mockApiFeatureEnabled,
|
||
setMockApiFeatureEnabled,
|
||
recorderState,
|
||
recordingSnapshotCount,
|
||
recordingList,
|
||
startRecording,
|
||
stopRecording,
|
||
startPlayback,
|
||
stopPlayback,
|
||
deleteRecording,
|
||
exportRecordingFile,
|
||
exportAllRecordingsFile,
|
||
importRecordingFile,
|
||
} = useTrainMenu();
|
||
const showResearchTools = __DEV__ || updatePermission;
|
||
const recordingSwipeRefs = useRef<Record<string, { close: () => void } | null>>({});
|
||
const [recordingFileStatus, setRecordingFileStatus] = useState<{
|
||
type: "info" | "success" | "error";
|
||
text: string;
|
||
}>({ type: "info", text: "録画JSONの書き出しと読み込みができます。" });
|
||
|
||
const closeRecordingSwipe = (id: string) => {
|
||
recordingSwipeRefs.current[id]?.close();
|
||
};
|
||
|
||
const confirmDeleteRecording = (id: string, label: string) => {
|
||
closeRecordingSwipe(id);
|
||
Alert.alert("録画を削除", `${label} の録画を削除しますか?`, [
|
||
{ text: "キャンセル", style: "cancel" },
|
||
{
|
||
text: "削除",
|
||
style: "destructive",
|
||
onPress: () => {
|
||
void deleteRecording(id);
|
||
},
|
||
},
|
||
]);
|
||
};
|
||
|
||
const setFileStatus = (type: "info" | "success" | "error", text: string) => {
|
||
setRecordingFileStatus({ type, text });
|
||
};
|
||
|
||
const handleExportRecordingFile = async (id: string, label: string) => {
|
||
try {
|
||
await exportRecordingFile(id);
|
||
setFileStatus("success", `${label} の録画JSONを書き出しました。`);
|
||
} catch (error) {
|
||
setFileStatus(
|
||
"error",
|
||
`録画JSONを書き出せませんでした: ${(error as Error).message}`,
|
||
);
|
||
}
|
||
};
|
||
|
||
const handleExportAllRecordingsFile = async () => {
|
||
try {
|
||
await exportAllRecordingsFile();
|
||
setFileStatus("success", `${recordingList.length}件の録画JSONを書き出しました。`);
|
||
} catch (error) {
|
||
setFileStatus(
|
||
"error",
|
||
`録画JSONを書き出せませんでした: ${(error as Error).message}`,
|
||
);
|
||
}
|
||
};
|
||
|
||
const handleImportRecordingFile = async () => {
|
||
try {
|
||
const result = await importRecordingFile();
|
||
if (!result) {
|
||
setFileStatus("info", "録画JSONの読み込みをキャンセルしました。");
|
||
return;
|
||
}
|
||
setFileStatus(
|
||
"success",
|
||
result.overwrittenCount > 0
|
||
? `${result.importedCount}件を読み込みました。${result.overwrittenCount}件は同じIDのため上書きしました。`
|
||
: `${result.importedCount}件を読み込みました。`,
|
||
);
|
||
} catch (error) {
|
||
setFileStatus(
|
||
"error",
|
||
`録画JSONを読み込めませんでした: ${(error as Error).message}`,
|
||
);
|
||
}
|
||
};
|
||
|
||
const openRecordingDatabase = () => {
|
||
const uri = `https://experimental.shikoku-railinfo.haruk.in/position-board?from=eachTrainInfo&userID=${encodeURIComponent(expoPushToken || "")}`;
|
||
const params = {
|
||
uri,
|
||
importRecordingDownloads: true,
|
||
useExitButton: false,
|
||
};
|
||
const parentNavigation = navigation.getParent?.();
|
||
if (parentNavigation) {
|
||
parentNavigation.navigate("generalWebView", params);
|
||
return;
|
||
}
|
||
navigation.navigate("generalWebView", params);
|
||
};
|
||
|
||
return (
|
||
<View style={[styles.container, { backgroundColor: fixed.primary }]}>
|
||
<SheetHeaderItem
|
||
title="調査ツール"
|
||
LeftItem={{
|
||
title: "< 戻る",
|
||
onPress: () => navigation.goBack(),
|
||
position: "left",
|
||
}}
|
||
/>
|
||
<ScrollView
|
||
style={[styles.content, { backgroundColor: colors.backgroundSecondary }]}
|
||
contentContainerStyle={styles.contentInner}
|
||
>
|
||
{!showResearchTools ? (
|
||
<View
|
||
style={[
|
||
styles.debugSection,
|
||
{
|
||
backgroundColor: colors.surface,
|
||
borderColor: colors.borderSecondary,
|
||
},
|
||
]}
|
||
>
|
||
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>利用できる調査ツールはありません</Text>
|
||
<Text style={[styles.debugDescription, { color: colors.textSecondary }]}>このページは開発・管理向けの調査機能を配置しています。</Text>
|
||
</View>
|
||
) : (
|
||
<>
|
||
<View
|
||
style={[
|
||
styles.debugSection,
|
||
{
|
||
backgroundColor: colors.surface,
|
||
borderColor: colors.borderSecondary,
|
||
},
|
||
]}
|
||
>
|
||
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>デバッグ: モックAPI検証</Text>
|
||
<Text style={[styles.debugDescription, { color: colors.textSecondary }]}>公式サイトの代わりにサンプルデータを流し込みます。</Text>
|
||
<View style={styles.switchRow}>
|
||
<Text style={[styles.debugCurrentText, { color: colors.textPrimary, fontSize: 14 }]}>モックAPI検証機能</Text>
|
||
<Switch
|
||
value={mockApiFeatureEnabled}
|
||
onValueChange={setMockApiFeatureEnabled}
|
||
color={fixed.primary}
|
||
/>
|
||
</View>
|
||
</View>
|
||
|
||
<View
|
||
style={[
|
||
styles.debugSection,
|
||
{
|
||
backgroundColor: colors.surface,
|
||
borderColor: colors.borderSecondary,
|
||
},
|
||
]}
|
||
>
|
||
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>デバッグ: 走行位置録画</Text>
|
||
<Text style={[styles.debugDescription, { color: colors.textSecondary }]}>ライブデータを録画してモックとして再生します。録画中はモックOFFになります。</Text>
|
||
|
||
<View style={styles.statusRow}>
|
||
<View
|
||
style={{
|
||
width: 10,
|
||
height: 10,
|
||
borderRadius: 5,
|
||
backgroundColor:
|
||
recorderState === "recording"
|
||
? "#e53935"
|
||
: recorderState === "playing"
|
||
? "#43a047"
|
||
: colors.borderSecondary,
|
||
}}
|
||
/>
|
||
<Text style={[styles.debugCurrentText, { color: colors.textSecondary, fontSize: 13 }]}>
|
||
{recorderState === "recording"
|
||
? `録画中… ${recordingSnapshotCount} スナップショット`
|
||
: recorderState === "playing"
|
||
? "再生中"
|
||
: `${recordingList.length} 件の録画`}
|
||
</Text>
|
||
</View>
|
||
|
||
<View style={styles.buttonRow}>
|
||
{recorderState === "idle" && (
|
||
<TouchableOpacity onPress={startRecording} style={styles.recordButton}>
|
||
<Text style={styles.primaryButtonText}>● 録画開始</Text>
|
||
</TouchableOpacity>
|
||
)}
|
||
{recorderState === "recording" && (
|
||
<TouchableOpacity onPress={stopRecording} style={[styles.neutralButton, { backgroundColor: colors.borderSecondary }]}>
|
||
<Text style={[styles.neutralButtonText, { color: colors.textPrimary }]}>■ 録画停止</Text>
|
||
</TouchableOpacity>
|
||
)}
|
||
{recorderState === "playing" && (
|
||
<TouchableOpacity onPress={stopPlayback} style={[styles.neutralButton, { backgroundColor: colors.borderSecondary }]}>
|
||
<Text style={[styles.neutralButtonText, { color: colors.textPrimary }]}>■ 再生停止</Text>
|
||
</TouchableOpacity>
|
||
)}
|
||
</View>
|
||
|
||
<View style={[styles.fileBox, { backgroundColor: colors.backgroundTertiary }]}>
|
||
<Text style={{ color: colors.textPrimary, fontSize: 13, fontWeight: "600" }}>録画JSONファイル</Text>
|
||
<View style={styles.buttonRow}>
|
||
<TouchableOpacity
|
||
onPress={() => {
|
||
void handleImportRecordingFile();
|
||
}}
|
||
disabled={recorderState === "recording"}
|
||
style={{
|
||
backgroundColor: recorderState === "recording" ? colors.borderSecondary : fixed.primary,
|
||
borderRadius: 8,
|
||
paddingHorizontal: 12,
|
||
paddingVertical: 8,
|
||
opacity: recorderState === "recording" ? 0.6 : 1,
|
||
}}
|
||
>
|
||
<Text style={{ color: fixed.textOnPrimary, fontWeight: "bold", fontSize: 12 }}>JSONを読み込む</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity
|
||
onPress={() => {
|
||
void handleExportAllRecordingsFile();
|
||
}}
|
||
disabled={recordingList.length === 0 || recorderState === "recording"}
|
||
style={{
|
||
backgroundColor:
|
||
recordingList.length === 0 || recorderState === "recording"
|
||
? colors.borderSecondary
|
||
: colors.surface,
|
||
borderRadius: 8,
|
||
borderWidth: 1,
|
||
borderColor: colors.borderSecondary,
|
||
paddingHorizontal: 12,
|
||
paddingVertical: 8,
|
||
opacity: recordingList.length === 0 || recorderState === "recording" ? 0.6 : 1,
|
||
}}
|
||
>
|
||
<Text style={{ color: colors.textPrimary, fontWeight: "bold", fontSize: 12 }}>全件を書き出す</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
<View
|
||
style={{
|
||
borderRadius: 8,
|
||
paddingHorizontal: 10,
|
||
paddingVertical: 8,
|
||
backgroundColor:
|
||
recordingFileStatus.type === "success"
|
||
? "#43a04722"
|
||
: recordingFileStatus.type === "error"
|
||
? "#e5393522"
|
||
: colors.surface,
|
||
borderWidth: 1,
|
||
borderColor:
|
||
recordingFileStatus.type === "success"
|
||
? "#43a04755"
|
||
: recordingFileStatus.type === "error"
|
||
? "#e5393555"
|
||
: colors.borderSecondary,
|
||
}}
|
||
>
|
||
<Text style={{ color: colors.textSecondary, fontSize: 12, lineHeight: 18 }}>{recordingFileStatus.text}</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{recordingList.length > 0 && recorderState !== "recording" && (
|
||
<View style={styles.recordingList}>
|
||
{recordingList.map((rec) => {
|
||
const isPlaying = recorderState === "playing";
|
||
const durationSec = Math.round(rec.durationMs / 1000);
|
||
const durationLabel = durationSec >= 60
|
||
? `${Math.floor(durationSec / 60)}分${durationSec % 60}秒`
|
||
: `${durationSec}秒`;
|
||
const dateLabel = new Date(rec.recordedAt).toLocaleString("ja-JP", {
|
||
month: "numeric",
|
||
day: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
});
|
||
const recordingRow = (
|
||
<TouchableOpacity
|
||
onPress={() => startPlayback(rec.id)}
|
||
onLongPress={() => {
|
||
void handleExportRecordingFile(rec.id, dateLabel);
|
||
}}
|
||
disabled={isPlaying}
|
||
activeOpacity={0.72}
|
||
style={{
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
backgroundColor: colors.backgroundSecondary,
|
||
borderRadius: 8,
|
||
padding: 12,
|
||
gap: 10,
|
||
opacity: isPlaying ? 0.6 : 1,
|
||
}}
|
||
>
|
||
<View style={{ flex: 1 }}>
|
||
<Text style={{ color: colors.textPrimary, fontSize: 13, fontWeight: "bold" }}>{dateLabel}</Text>
|
||
<Text style={{ color: colors.textSecondary, fontSize: 11 }}>{rec.snapshotCount} コマ / {durationLabel}</Text>
|
||
</View>
|
||
<View style={{ alignItems: "flex-end", gap: 4 }}>
|
||
<View style={styles.playbackHint}>
|
||
<MaterialCommunityIcons
|
||
name={isPlaying ? "pause-circle-outline" : "play-circle-outline"}
|
||
size={18}
|
||
color={isPlaying ? colors.textTertiary : "#43a047"}
|
||
/>
|
||
<Text
|
||
style={{
|
||
color: isPlaying ? colors.textTertiary : colors.textPrimary,
|
||
fontSize: 12,
|
||
fontWeight: "600",
|
||
}}
|
||
>
|
||
{isPlaying ? "再生中は操作不可" : "タップで再生"}
|
||
</Text>
|
||
</View>
|
||
{!isPlaying && (
|
||
<Text style={{ color: colors.textTertiary, fontSize: 10 }}>長押しで書き出し / 左へスワイプで削除</Text>
|
||
)}
|
||
</View>
|
||
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.iconSecondary} />
|
||
</TouchableOpacity>
|
||
);
|
||
|
||
if (isPlaying) {
|
||
return <View key={rec.id}>{recordingRow}</View>;
|
||
}
|
||
|
||
return (
|
||
<Swipeable
|
||
key={rec.id}
|
||
ref={(instance) => {
|
||
recordingSwipeRefs.current[rec.id] = instance;
|
||
}}
|
||
friction={2}
|
||
overshootRight={false}
|
||
rightThreshold={48}
|
||
renderRightActions={() => (
|
||
<View style={styles.deleteAction}>
|
||
<MaterialCommunityIcons name="trash-can-outline" size={18} color="#fff" />
|
||
<Text style={styles.deleteActionText}>削除</Text>
|
||
</View>
|
||
)}
|
||
onSwipeableOpen={() => confirmDeleteRecording(rec.id, dateLabel)}
|
||
>
|
||
{recordingRow}
|
||
</Swipeable>
|
||
);
|
||
})}
|
||
</View>
|
||
)}
|
||
|
||
<TouchableOpacity
|
||
onPress={openRecordingDatabase}
|
||
activeOpacity={0.78}
|
||
style={[
|
||
styles.databaseButton,
|
||
{
|
||
backgroundColor: fixed.primary,
|
||
borderColor: fixed.primary,
|
||
},
|
||
]}
|
||
>
|
||
<MaterialCommunityIcons
|
||
name="database-search-outline"
|
||
size={18}
|
||
color={fixed.textOnPrimary}
|
||
/>
|
||
<Text style={[styles.databaseButtonText, { color: fixed.textOnPrimary }]}>
|
||
全録データベースを参照
|
||
</Text>
|
||
</TouchableOpacity>
|
||
|
||
</View>
|
||
</>
|
||
)}
|
||
</ScrollView>
|
||
</View>
|
||
);
|
||
};
|
||
|
||
const styles = StyleSheet.create({
|
||
container: {
|
||
flex: 1,
|
||
backgroundColor: "#0099CC",
|
||
},
|
||
content: {
|
||
flex: 1,
|
||
backgroundColor: "#f8f8fc",
|
||
},
|
||
contentInner: {
|
||
paddingHorizontal: 14,
|
||
paddingBottom: 40,
|
||
paddingTop: 20,
|
||
gap: 12,
|
||
},
|
||
debugSection: {
|
||
borderRadius: 12,
|
||
borderWidth: 1,
|
||
padding: 14,
|
||
gap: 10,
|
||
},
|
||
debugTitle: {
|
||
fontSize: 15,
|
||
fontWeight: "bold",
|
||
},
|
||
debugDescription: {
|
||
fontSize: 12,
|
||
lineHeight: 18,
|
||
},
|
||
debugCurrentText: {
|
||
fontSize: 11,
|
||
lineHeight: 16,
|
||
},
|
||
switchRow: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
marginTop: 8,
|
||
},
|
||
statusRow: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
marginTop: 8,
|
||
gap: 8,
|
||
},
|
||
buttonRow: {
|
||
flexDirection: "row",
|
||
gap: 8,
|
||
marginTop: 10,
|
||
flexWrap: "wrap",
|
||
},
|
||
recordButton: {
|
||
backgroundColor: "#e53935",
|
||
borderRadius: 8,
|
||
paddingHorizontal: 14,
|
||
paddingVertical: 8,
|
||
},
|
||
neutralButton: {
|
||
borderRadius: 8,
|
||
paddingHorizontal: 14,
|
||
paddingVertical: 8,
|
||
},
|
||
primaryButtonText: {
|
||
color: "#fff",
|
||
fontWeight: "bold",
|
||
fontSize: 13,
|
||
},
|
||
neutralButtonText: {
|
||
fontWeight: "bold",
|
||
fontSize: 13,
|
||
},
|
||
fileBox: {
|
||
marginTop: 4,
|
||
gap: 8,
|
||
borderRadius: 8,
|
||
padding: 10,
|
||
},
|
||
recordingList: {
|
||
marginTop: 10,
|
||
gap: 6,
|
||
},
|
||
playbackHint: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
gap: 4,
|
||
},
|
||
deleteAction: {
|
||
width: 96,
|
||
borderRadius: 8,
|
||
backgroundColor: "#e53935",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
marginLeft: 6,
|
||
},
|
||
deleteActionText: {
|
||
color: "#fff",
|
||
fontSize: 11,
|
||
fontWeight: "bold",
|
||
marginTop: 4,
|
||
},
|
||
databaseButton: {
|
||
minHeight: 46,
|
||
borderRadius: 10,
|
||
borderWidth: 1,
|
||
paddingHorizontal: 14,
|
||
paddingVertical: 12,
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
gap: 8,
|
||
marginTop: 4,
|
||
},
|
||
databaseButtonText: {
|
||
fontSize: 14,
|
||
fontWeight: "bold",
|
||
},
|
||
});
|