見出し画像

グローカルアプリ開発(第12話)


個人事業主にとって最高のホテル

それは間違いなくコンフォートホテルです。
なぜなら、宿泊するとライブラリーカフェ無料券が月5回分付いてくるから。
以前全国を回りながらサブスくらしをしていたのですが、コンフォートホテルさんは全国チェーンの為、行く先々でライブラリーカフェのお世話になりました。
正直スタバのようなカフェで仕事をするより、捗ります。ホテルに宿泊される方は、だいたいうるさくしない方が多いので。
最近はインバウンド勢が強いので、夕食の時間帯は微妙ですが、比較的落ち着いています。
家で仕事すればいいじゃないか?と思う方もいらっしゃるかもしれませんが、家ってベッドが近くにありますよね?その重力に大抵人間は勝てません。

デジタルマーケティング現況

閑話休題で、デジタルマーケティングをはじめて12日目に入りました。コード記載をする背景から、マークダウンでコンテンツマーケティングをしたかったので、Zenn・Note・Qiitaにてランダム公開しました。Lがライクで、Fがフォロー、Vがビューです。全部で11話通し620人の方にご覧になっていただきました。

そこから18人ホームページに来てくれて、営業の人からお問い合わせいただいた感じです。お客さんが来る日はおそらく1年後かな(笑)。

尚、各プラットフォームの現在の印象は以下です。

  • Zenn:Qiitaよりは若いメディアなので、割と気軽にライクがある。自前でコミュニティを運営している人が、おそらく下心があってか、いつもいいねしてくる。

  • Note:プログラムにはあまり興味はないが、少ないビューでライクやフォローが比較的多く、ライクマインドな人が多い。

  • Qiita:絶対数が多めでビューも多いが、そんな簡単にライクやフォローはしない、玄人勢が多い。

まだ迷いはありますが、どこかのタイミングで一つに絞っていきたいと思います。

AIアプリをつくる

現在路線アプリを開発しています。AIエージェント元年と言われる今年(2025)ですから、人の役に立つAIを活用した路線アプリにしたいと思います。本日はすすきののライブラリーカフェにおりますが、いい感じで機能していて、GPSで最寄り駅を取得し、徒歩時間を逆算。そして、乗車予定時刻を表示できています。

インバウンド向けに改良

札幌中心部は本当に中国人が多いです。中国語を聞かない日はないくらい。中国本土では、土地を所有することができないので、近くの北海道の土地を富裕層が買い漁ってるそうです。
あとは、雪とか、映画のロケ地とかも北海道は多く、魅力的なのかもしれません。
先日観光に来ていた香港の中国人留学生と話していた時に、中国と香港の生活は違う?と聞いてみたら、「香港は中国です。」と回答され、ビビりました(笑)。

需要が伸びているところに張るのが、ビジネスの基本ですから、この札幌地下鉄システムを多言語対応させていきたいと思います。

https://wwwtb.mlit.go.jp/hokkaido/content/000284820.pdf

ちなみに、国別観光客数は上記によると以下ランキングでした。

  1. 韓国

  2. 台湾

  3. 中国

  4. 香港

  5. タイ

  6. フィリピン

  7. シンガポール

  8. ベトナム

  9. カナダ

  10. イギリス

  11. フランス

  12. オーストラリア

今日の成果物

政治的事情を考慮すると、こんな感じですね。

 

ソース

全部i18にすると、駅名とかワヤになるので、現時点ではGoogle APIに依存します。

// React Native
import React, { useEffect, useState } from "react";
import * as Location from "expo-location";
import { ThemedText } from "./ThemedText";
import { Alert, Button, View, Text, TouchableOpacity } from "react-native";
import Constants from "expo-constants";
import { TranslateService } from "../services/translateService";
import LanguageSelector from "./LanguageSelector";

// インターフェース定義
interface Station {
  coords: {
    lat: number;
    lng: number;
  };
  name: string;
}

interface Coordinates {
  latitude: number;
  longitude: number;
}

interface APIError {
  message: string;
  status?: number;
}

interface SubwaySchedule {
  direction: string;
  linetype: string;
  nearestStation: string;
  weekdayOrEnd: string;
  currentHour: string;
  nextHour: string;
  currentHourTimes: string;
  nextHourTimes: string;
  nextTrains: string[];
  searchInfo: {
    queryTime: string;
    arrivalTime: string;
    walkingMinutes: number;
  };
  calculatedDirection: number;
  colored: boolean;
}

interface UIText {
  loading: string;
  error: string;
  nearestStation: string;
  walkingTime: string;
  minutes: string;
  calculating: string;
  showOthers: string;
  hideOthers: string;
  endOfService: string;
  errorTitle: string;
  unexpectedError: string;
}

type Languages =
  | "ja"
  | "ko"
  | "zh-TW"
  | "zh-CN"
  | "th"
  | "fil"
  | "vi"
  | "en"
  | "fr";

const uiText: Record<Languages, UIText> = {
  ja: {
    loading: "読み込み中",
    error: "エラーが発生しました",
    nearestStation: "最寄り駅",
    walkingTime: "徒歩時間",
    minutes: "分",
    calculating: "計算中",
    showOthers: "他の人を表示",
    hideOthers: "他の人を非表示",
    endOfService: "サービス終了",
    errorTitle: "エラー",
    unexpectedError: "予期しないエラーが発生しました",
  },
  ko: {
    loading: "로딩 중",
    error: "오류가 발생했습니다",
    nearestStation: "가장 가까운 역",
    walkingTime: "도보 시간",
    minutes: "분",
    calculating: "계산 중",
    showOthers: "다른 사람 보기",
    hideOthers: "다른 사람 숨기기",
    endOfService: "서비스 종료",
    errorTitle: "오류",
    unexpectedError: "예상치 못한 오류가 발생했습니다",
  },
  "zh-TW": {
    loading: "載入中",
    error: "發生錯誤",
    nearestStation: "最近車站",
    walkingTime: "步行時間",
    minutes: "分鐘",
    calculating: "計算中",
    showOthers: "顯示其他人",
    hideOthers: "隱藏其他人",
    endOfService: "服務結束",
    errorTitle: "錯誤",
    unexpectedError: "發生了意外錯誤",
  },
  "zh-CN": {
    loading: "加载中",
    error: "发生错误",
    nearestStation: "最近车站",
    walkingTime: "步行时间",
    minutes: "分钟",
    calculating: "计算中",
    showOthers: "显示其他人",
    hideOthers: "隐藏其他人",
    endOfService: "服务结束",
    errorTitle: "错误",
    unexpectedError: "发生了意外错误",
  },
  th: {
    loading: "กำลังโหลด",
    error: "เกิดข้อผิดพลาด",
    nearestStation: "สถานีที่ใกล้ที่สุด",
    walkingTime: "เวลาเดิน",
    minutes: "นาที",
    calculating: "กำลังคำนวณ",
    showOthers: "แสดงผู้อื่น",
    hideOthers: "ซ่อนผู้อื่น",
    endOfService: "บริการสิ้นสุด",
    errorTitle: "ข้อผิดพลาด",
    unexpectedError: "เกิดข้อผิดพลาดที่ไม่คาดคิด",
  },
  fil: {
    loading: "Naglo-load",
    error: "Nagkaroon ng error",
    nearestStation: "Pinakamalapit na Istasyon",
    walkingTime: "Oras ng Paglakad",
    minutes: "minuto",
    calculating: "Nagkakalculate",
    showOthers: "Ipakita ang Iba",
    hideOthers: "Itago ang Iba",
    endOfService: "Pagtatapos ng Serbisyo",
    errorTitle: "Error",
    unexpectedError: "Nagkaroon ng hindi inaasahang error",
  },
  vi: {
    loading: "Đang tải",
    error: "Đã xảy ra lỗi",
    nearestStation: "Ga gần nhất",
    walkingTime: "Thời gian đi bộ",
    minutes: "phút",
    calculating: "Đang tính toán",
    showOthers: "Hiển thị người khác",
    hideOthers: "Ẩn người khác",
    endOfService: "Kết thúc dịch vụ",
    errorTitle: "Lỗi",
    unexpectedError: "Đã xảy ra lỗi ngoài ý muốn",
  },
  en: {
    loading: "Loading",
    error: "An error occurred",
    nearestStation: "Nearest Station",
    walkingTime: "Walking Time",
    minutes: "minutes",
    calculating: "Calculating",
    showOthers: "Show Others",
    hideOthers: "Hide Others",
    endOfService: "End of Service",
    errorTitle: "Error",
    unexpectedError: "An unexpected error occurred",
  },
  fr: {
    loading: "Chargement",
    error: "Une erreur est survenue",
    nearestStation: "Gare la plus proche",
    walkingTime: "Temps de marche",
    minutes: "minutes",
    calculating: "Calcul en cours",
    showOthers: "Afficher les autres",
    hideOthers: "Cacher les autres",
    endOfService: "Fin du service",
    errorTitle: "Erreur",
    unexpectedError: "Une erreur inattendue est survenue",
  },
};

const SubwayTimer: React.FC = () => {
  const [location, setLocation] = useState<Location.LocationObject | null>(
    null
  );
  const [nearestStation, setNearestStation] = useState<Station | null>(null);
  const [walkingTime, setWalkingTime] = useState<number | null>(null);
  const [subwaySchedules, setSubwaySchedules] = useState<SubwaySchedule[]>([]);
  const [error, setError] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [log, setLog] = useState<string[]>([]);
  const [showOtherDirections, setShowOtherDirections] = useState(false);
  const [currentLanguage, setCurrentLanguage] = useState<Languages>("ja");
  const [translatedStationName, setTranslatedStationName] =
    useState<string>("");
  const [translatedSchedules, setTranslatedSchedules] = useState<
    SubwaySchedule[]
  >([]);

  const apiKey = Constants.expoConfig?.extra?.API_KEY;
  const subwayApiKey = Constants.expoConfig?.extra?.SUBWAY_API_KEY;
  const translateApiKey = Constants.expoConfig?.extra?.TRANSLATE_API_KEY;
  const translateService = new TranslateService(translateApiKey || "");

  const requestLocationPermission = async () => {
    const { status } = await Location.requestForegroundPermissionsAsync();
    if (status !== "granted") {
      throw new Error(
        currentLanguage === "ja"
          ? "位置情報の許可が必要です"
          : "Location permission is required"
      );
    }
  };

  const getCurrentLocation = async () => {
    try {
      return await Location.getCurrentPositionAsync({
        accuracy: Location.Accuracy.Balanced,
      });
    } catch (error) {
      throw new Error(
        currentLanguage === "ja"
          ? "現在位置を取得できませんでした"
          : "Could not get current location"
      );
    }
  };

  const findNearestSubwayStation = async (
    lat: number,
    lng: number
  ): Promise<Station> => {
    try {
      const response = await fetch(
        `https://maps.googleapis.com/maps/api/place/nearbysearch/json?location=${lat},${lng}&radius=1000&type=subway_station&key=${apiKey}`
      );

      const data = await response.json();

      if (!response.ok) {
        throw new Error(
          `${
            currentLanguage === "ja"
              ? "最寄り駅の検索に失敗しました"
              : "Failed to search nearest station"
          }: ${data.status || response.status} - ${
            data.error_message || "Unknown error"
          }`
        );
      }

      if (!data.results || data.results.length === 0) {
        throw new Error(
          currentLanguage === "ja"
            ? "近くに地下鉄駅が見つかりませんでした"
            : "No subway stations found nearby"
        );
      }

      const station = data.results[0];
      if (
        !station.geometry?.location?.lat ||
        !station.geometry?.location?.lng
      ) {
        throw new Error(
          currentLanguage === "ja"
            ? "駅の座標情報が不正です"
            : "Invalid station coordinate information"
        );
      }

      return {
        name: station.name,
        coords: {
          lat: station.geometry.location.lat,
          lng: station.geometry.location.lng,
        },
      };
    } catch (error) {
      if (error instanceof Error) {
        console.error("Station search error:", error.message);
        if (__DEV__) {
          console.error("Full error:", error);
        }
        throw error;
      }
      throw new Error(
        currentLanguage === "ja"
          ? "駅情報の取得に失敗しました"
          : "Failed to get station information"
      );
    }
  };

  const calculateWalkingTime = async (
    origin: Coordinates,
    destination: Station["coords"]
  ): Promise<number> => {
    try {
      const response = await fetch(
        `https://maps.googleapis.com/maps/api/directions/json?origin=${origin.latitude},${origin.longitude}&destination=${destination.lat},${destination.lng}&mode=walking&key=${apiKey}`
      );

      if (!response.ok) {
        throw new Error(
          currentLanguage === "ja"
            ? "経路の取得に失敗しました"
            : "Failed to get route information"
        );
      }

      const data = await response.json();

      if (
        !data.routes?.length ||
        !data.routes[0].legs?.length ||
        !data.routes[0].legs[0].duration
      ) {
        throw new Error(
          currentLanguage === "ja"
            ? "経路情報が不正です"
            : "Invalid route information"
        );
      }

      return Math.ceil(data.routes[0].legs[0].duration.value / 60);
    } catch (error) {
      throw new Error(
        currentLanguage === "ja"
          ? "徒歩時間の計算に失敗しました"
          : "Failed to calculate walking time"
      );
    }
  };

  const postNearestStationAndWalkingTime = async (
    stationName: string,
    walkingMinutes: number,
    latitude: number,
    longitude: number
  ): Promise<void> => {
    try {
      if (!subwayApiKey) {
        throw new Error(
          currentLanguage === "ja"
            ? "SUBWAY_API_KEYが設定されていません"
            : "SUBWAY_API_KEY is not configured"
        );
      }

      const requestBody = JSON.stringify({
        nearestStation: stationName,
        walkingMinutes: walkingMinutes,
        location: {
          latitude: latitude,
          longitude: longitude,
        },
      });

      const response = await fetch(
        "https://udtetq5gol.execute-api.ap-northeast-1.amazonaws.com/staging",
        {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "x-api-key": subwayApiKey.trim(),
          },
          body: requestBody,
        }
      );

      const responseText = await response.text();

      if (!response.ok) {
        let errorMessage =
          currentLanguage === "ja"
            ? "データの送信に失敗しました"
            : "Failed to send data";
        try {
          const errorData = JSON.parse(responseText) as APIError;
          errorMessage = errorData.message || errorMessage;
        } catch (e) {
          errorMessage = responseText || errorMessage;
        }
        throw new Error(errorMessage);
      }

      const jsonResponse = JSON.parse(responseText);
      const scheduleData = JSON.parse(jsonResponse.body);
      setSubwaySchedules(scheduleData);
    } catch (error) {
      if (error instanceof Error) {
        throw new Error(
          `${
            currentLanguage === "ja"
              ? "データ送信エラー"
              : "Data transmission error"
          }: ${error.message}`
        );
      }
      throw new Error(
        currentLanguage === "ja"
          ? "データの送信に失敗しました"
          : "Failed to send data"
      );
    }
  };

  const logLocation = (location: Location.LocationObject) => {
    const logEntry =
      currentLanguage === "ja"
        ? `位置情報: 緯度: ${location.coords.latitude}, 経度: ${location.coords.longitude}`
        : `Location: Latitude: ${location.coords.latitude}, Longitude: ${location.coords.longitude}`;
    setLog((prevLog) => [...prevLog, logEntry]);
  };

  const hasColoredSchedules = (schedules: SubwaySchedule[]): boolean => {
    return schedules.some((schedule) => schedule.colored);
  };

  const formatDepartureInfo = (schedule: SubwaySchedule) => {
    if (!schedule.nextTrains || schedule.nextTrains.length === 0) {
      return uiText[currentLanguage].endOfService;
    }

    const [first, ...rest] = schedule.nextTrains;
    return `${schedule.currentHour} ${first}${
      uiText[currentLanguage].minutes
    } ${
      rest.length > 0
        ? `(${rest.join(uiText[currentLanguage].minutes + " ")}${
            uiText[currentLanguage].minutes
          })`
        : ""
    }`;
  };

  const initializeSubwayTimer = async () => {
    try {
      setIsLoading(true);

      // Ensure API keys are available
      if (!apiKey || !subwayApiKey || !translateApiKey) {
        throw new Error(
          currentLanguage === "ja"
            ? "APIキーが設定されていません"
            : "API keys are not configured"
        );
      }

      // Request location permission
      await requestLocationPermission();

      // Get the current location
      const currentLocation = await getCurrentLocation();
      logLocation(currentLocation);

      // Find the nearest subway station
      const station = await findNearestSubwayStation(
        currentLocation.coords.latitude,
        currentLocation.coords.longitude
      );
      setNearestStation(station);

      // Calculate walking time
      const walkingMinutes = await calculateWalkingTime(
        currentLocation.coords,
        station.coords
      );
      setWalkingTime(walkingMinutes);

      // Post the nearest station and walking time to the API
      await postNearestStationAndWalkingTime(
        station.name,
        walkingMinutes,
        currentLocation.coords.latitude,
        currentLocation.coords.longitude
      );

      setIsLoading(false);
    } catch (error) {
      setIsLoading(false);
      if (error instanceof Error) {
        setError(error.message);
      }
    }
  };

  const translateSchedule = async (
    schedule: SubwaySchedule,
    targetLang: Languages
  ): Promise<SubwaySchedule> => {
    if (!translateService || targetLang === "ja") {
      return schedule;
    }

    try {
      const [translatedLinetype, translatedDirection, translatedHour] =
        await translateService.translateBatch(
          [
            schedule.linetype,
            schedule.direction,
            schedule.currentHour, // 「時」を含めたまま翻訳
          ],
          targetLang
        );

      return {
        ...schedule,
        linetype: translatedLinetype,
        direction: translatedDirection,
        currentHour: translatedHour, // 翻訳された時刻をそのまま使用
      };
    } catch (error) {
      console.error("Schedule translation error:", error);
      return schedule;
    }
  };

  const translateSchedules = async (
    schedules: SubwaySchedule[],
    targetLang: Languages
  ): Promise<SubwaySchedule[]> => {
    if (!translateService || targetLang === "ja") {
      return schedules;
    }

    try {
      return await Promise.all(
        schedules.map((schedule) => translateSchedule(schedule, targetLang))
      );
    } catch (error) {
      console.error("Schedules translation error:", error);
      return schedules;
    }
  };

  useEffect(() => {
    initializeSubwayTimer();
  }, []);

  useEffect(() => {
    const translateStation = async () => {
      if (nearestStation?.name) {
        try {
          // `currentLanguage` に基づいて翻訳
          const translated = await translateService.translate(
            nearestStation.name,
            currentLanguage
          );
          setTranslatedStationName(translated);
        } catch (error) {
          console.error("Station name translation error:", error);
        }
      } else {
        // nearestStation?.name がない場合は翻訳しない
        setTranslatedStationName("");
      }
    };

    translateStation();
  }, [currentLanguage, nearestStation?.name]);

  useEffect(() => {
    const translateCurrentSchedules = async () => {
      const translated = await translateSchedules(
        subwaySchedules,
        currentLanguage
      );
      setTranslatedSchedules(translated);
    };

    translateCurrentSchedules();
  }, [subwaySchedules, currentLanguage]);

  if (isLoading) {
    return <ThemedText>{uiText[currentLanguage].loading}</ThemedText>;
  }

  if (error) {
    return (
      <ThemedText>
        {uiText[currentLanguage].error}
        {error}
      </ThemedText>
    );
  }

  const hasColored = hasColoredSchedules(subwaySchedules);

  return (
    <View>
      <View
        style={{
          flexDirection: "row",
          justifyContent: "space-between",
          alignItems: "center",
          marginBottom: 10,
        }}
      >
        {/* LanguageSelector コンポーネントをここに埋め込む */}
        <LanguageSelector
          currentLanguage={currentLanguage}
          setCurrentLanguage={setCurrentLanguage}
        />

        {/* タイマーリセットボタン */}
        <Button title="🔁" onPress={initializeSubwayTimer} />
      </View>

      <ThemedText>
        {uiText[currentLanguage].nearestStation}
        {translatedStationName ||
          nearestStation?.name ||
          uiText[currentLanguage].calculating}
      </ThemedText>

      <ThemedText>
        {uiText[currentLanguage].walkingTime}
        {walkingTime
          ? `${walkingTime}${uiText[currentLanguage].minutes}`
          : uiText[currentLanguage].calculating}
      </ThemedText>

      {Array.isArray(translatedSchedules) &&
        translatedSchedules
          .filter((schedule) => schedule.colored)
          .map((schedule, index) => (
            <Text
              key={`colored-${index}`}
              style={{
                color: "#4A90E2",
                marginVertical: 4,
                fontWeight: "bold",
              }}
            >
              {schedule.linetype} {schedule.direction}:
              {formatDepartureInfo(schedule)}
            </Text>
          ))}

      {hasColored && (
        <TouchableOpacity
          onPress={() => setShowOtherDirections(!showOtherDirections)}
          style={{
            padding: 8,
            marginVertical: 8,
            backgroundColor: "#f0f0f0",
            borderRadius: 4,
          }}
        >
          <Text style={{ color: "gray" }}>
            {showOtherDirections
              ? uiText[currentLanguage].hideOthers
              : uiText[currentLanguage].showOthers}
          </Text>
        </TouchableOpacity>
      )}

      {Array.isArray(translatedSchedules) &&
        (!hasColored || showOtherDirections) &&
        translatedSchedules
          .filter((schedule) => !schedule.colored)
          .map((schedule, index) => (
            <Text
              key={`other-${index}`}
              style={{
                color: "black",
                marginVertical: 4,
              }}
            >
              {schedule.linetype} {schedule.direction}:
              {formatDepartureInfo(schedule)}
            </Text>
          ))}
    </View>
  );
};

export default SubwayTimer;

まとめ

これでこのアプリも世界レベルに近づきましたので、次は地下鉄以外の乗り物に挑戦したいと思います。

それではまた、ごきげんよう🍀

いいなと思ったら応援しよう!