Files
jrshikoku/components/Settings/ResearchToolsSettings.tsx

529 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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",
},
});