diff --git a/.env.example b/.env.example index 13ac59f..6c8896c 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ MAPBOX_ACCESS_TOKEN= -RNMAPBOX_MAPS_DOWNLOAD_TOKEN= \ No newline at end of file +RNMAPBOX_MAPS_DOWNLOAD_TOKEN= +BIRDPLAN_DOMAIN=https://api.birdplan.app \ No newline at end of file diff --git a/app.config.js b/app.config.js index f6767dd..4c7c791 100644 --- a/app.config.js +++ b/app.config.js @@ -4,7 +4,7 @@ module.exports = ({ config }) => ({ ...(config.expo || {}), name: "OpenBirding", slug: "OpenBirding", - version: "1.7.2", + version: "1.8.0", orientation: "portrait", icon: "./assets/images/logo.png", scheme: "openbirding", @@ -64,6 +64,7 @@ module.exports = ({ config }) => ({ router: {}, eas: { projectId: "2944a151-98b6-4d2a-9104-65facf9def35" }, MAPBOX_ACCESS_TOKEN: process.env.MAPBOX_ACCESS_TOKEN, + BIRDPLAN_DOMAIN: process.env.BIRDPLAN_DOMAIN, }, updates: { url: "https://u.expo.dev/2944a151-98b6-4d2a-9104-65facf9def35", diff --git a/app/_layout.tsx b/app/_layout.tsx index 4088c26..0747e93 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -131,6 +131,24 @@ export default function RootLayout() { headerShadowVisible: false, }} /> + + + {children} + + ); + } + return {children}; +} + +export default function BirdPlanImportPage() { + const router = useRouter(); + const queryClient = useQueryClient(); + const [code, setCode] = useState(""); + const [isSaving, setIsSaving] = useState(false); + const firstSlotRef = useRef(null); + + const invalidate = useCallback(() => { + queryClient.invalidateQueries({ queryKey: ["trips"] }); + queryClient.invalidateQueries({ queryKey: ["savedHotspots"] }); + queryClient.invalidateQueries({ queryKey: ["savedPlaces"] }); + queryClient.invalidateQueries({ queryKey: ["hotspots"] }); + }, [queryClient]); + + const performImport = useCallback( + async (data: BirdPlanTripData) => { + try { + setIsSaving(true); + await importTrip(data); + invalidate(); + Toast.show({ type: "success", text1: `Imported “${data.name}”` }); + router.back(); + } catch { + Toast.show({ type: "error", text1: "Failed to save trip" }); + } finally { + setIsSaving(false); + } + }, + [invalidate, router] + ); + + const importMutation = useMutation({ + mutationFn: async (codeValue: string) => { + const data = await fetchBirdPlanTrip(codeValue); + const existing = await getTripById(data.id); + return { data, existing }; + }, + onSuccess: async ({ data, existing }) => { + const proceed = () => { + void performImport(data); + }; + + if (existing) { + Alert.alert( + "Replace Trip Content?", + `This code is for “${existing.name}”, which you've already imported. All hotspots, pins, and notes from the previous import will be replaced.`, + [ + { text: "Cancel", style: "cancel" }, + { text: "Replace", style: "destructive", onPress: proceed }, + ] + ); + } else { + proceed(); + } + }, + onError: (error: unknown) => { + Toast.show({ type: "error", text1: codeErrorMessage(error) }); + setCode(""); + setTimeout(() => firstSlotRef.current?.focus(), 100); + }, + }); + + const handleSubmit = useCallback( + (codeValue: string) => { + if (codeValue.length !== CODE_LENGTH || importMutation.isPending || isSaving) return; + importMutation.mutate(codeValue); + }, + [importMutation, isSaving] + ); + + useEffect(() => { + const timer = setTimeout(() => firstSlotRef.current?.focus(), 250); + return () => clearTimeout(timer); + }, []); + + const hasCompleteCode = code.length === CODE_LENGTH; + const busy = importMutation.isPending || isSaving; + + return ( + + Enter Code + + + + Generate a one-time code in BirdPlan.app and enter it below. + + + handleSubmit(code)} + disabled={!hasCompleteCode || busy} + style={({ pressed }) => [ + tw`rounded-full py-3 mt-5 items-center justify-center flex-row`, + hasCompleteCode && !busy ? tw`bg-blue-600` : tw`bg-gray-200`, + pressed && hasCompleteCode && !busy ? tw`opacity-80` : null, + ]} + > + {busy ? ( + + ) : ( + + Import + + )} + + + + + ); +} + +type CodeInputProps = { + value: string; + onChange: (value: string) => void; + onComplete: (value: string) => void; + disabled?: boolean; + firstSlotRef: React.MutableRefObject; +}; + +function CodeInput({ value, onChange, onComplete, disabled, firstSlotRef }: CodeInputProps) { + const refs = useRef<(TextInput | null)[]>([]); + + const handleChange = (index: number, text: string) => { + const digit = text.replace(/\D/g, "").slice(-1); + const next = value.split(""); + next[index] = digit; + const joined = next.join("").slice(0, CODE_LENGTH); + onChange(joined); + + if (digit && index < CODE_LENGTH - 1) { + refs.current[index + 1]?.focus(); + } + if (digit && joined.length === CODE_LENGTH) { + Keyboard.dismiss(); + onComplete(joined); + } + }; + + const handleKeyPress = (index: number, e: NativeSyntheticEvent) => { + if (e.nativeEvent.key === "Backspace" && !value[index] && index > 0) { + const next = value.split(""); + next[index - 1] = ""; + onChange(next.join("")); + refs.current[index - 1]?.focus(); + } + }; + + const renderSlot = (index: number) => { + const digit = value[index] ?? ""; + const focused = value.length === index; + const filled = !!digit; + return ( + { + refs.current[index] = el; + if (index === 0) firstSlotRef.current = el; + }} + value={digit} + onChangeText={(text) => handleChange(index, text)} + onKeyPress={(e) => handleKeyPress(index, e)} + keyboardType="number-pad" + inputMode="numeric" + maxLength={1} + selectTextOnFocus + editable={!disabled} + textContentType="oneTimeCode" + style={[ + tw`w-11 h-14 text-center bg-white rounded-lg border mx-1`, + { + fontSize: 22, + fontWeight: "600", + color: tw.color("gray-900"), + }, + focused ? tw`border-blue-500` : filled ? tw`border-gray-300` : tw`border-gray-200`, + ]} + /> + ); + }; + + return ( + + {[0, 1, 2].map(renderSlot)} + + {[3, 4, 5].map(renderSlot)} + + ); +} diff --git a/app/settings-birdplan.tsx b/app/settings-birdplan.tsx new file mode 100644 index 0000000..cd562e0 --- /dev/null +++ b/app/settings-birdplan.tsx @@ -0,0 +1,249 @@ +import { BirdPlanError, fetchBirdPlanTrip } from "@/lib/birdplan"; +import { deleteTrip, getTrips, importTrip } from "@/lib/database"; +import tw from "@/lib/tw"; +import { Trip } from "@/lib/types"; +import { Ionicons } from "@expo/vector-icons"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect"; +import { Href, useRouter } from "expo-router"; +import React, { useCallback, useRef, useState } from "react"; +import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, Text, TouchableOpacity, View, ViewStyle } from "react-native"; +import Swipeable, { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; +import Toast from "react-native-toast-message"; + +function Card({ children }: { children: React.ReactNode }) { + const useGlass = Platform.OS === "ios" && isLiquidGlassAvailable(); + const cardStyle: ViewStyle = { borderRadius: 12, overflow: "hidden" }; + + if (useGlass) { + return ( + + {children} + + ); + } + return {children}; +} + +function formatMonthRange(start: number | null, end: number | null): string | null { + if (start == null || end == null) return null; + const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + const s = months[start - 1]; + const e = months[end - 1]; + if (!s || !e) return null; + return s === e ? s : `${s}–${e}`; +} + +type TripRowProps = { + trip: Trip; + onUpdate: (trip: Trip) => void; + onDelete: (trip: Trip) => void; + isBusy: boolean; +}; + +function TripRow({ trip, onUpdate, onDelete, isBusy }: TripRowProps) { + const swipeableRef = useRef(null); + const monthRange = formatMonthRange(trip.start_month, trip.end_month); + + const handleDelete = () => { + swipeableRef.current?.close(); + onDelete(trip); + }; + + const renderRightActions = () => ( + + + Delete + + + ); + + const content = ( + + + {trip.name} + + {trip.hotspot_count} hotspot{trip.hotspot_count === 1 ? "" : "s"} · {trip.marker_count} pin + {trip.marker_count === 1 ? "" : "s"} + {monthRange ? ` · ${monthRange}` : ""} + + + {isBusy ? ( + + ) : ( + onUpdate(trip)} + style={tw`py-2 rounded-xl border border-blue-500 bg-blue-500`} + > + Update + + )} + + ); + + return ( + + {content} + + ); +} + +export default function BirdPlanSettingsPage() { + const router = useRouter(); + const queryClient = useQueryClient(); + const [busyTripId, setBusyTripId] = useState(null); + + const { data: trips = [], isLoading } = useQuery({ queryKey: ["trips"], queryFn: getTrips }); + + const invalidate = useCallback(() => { + queryClient.invalidateQueries({ queryKey: ["trips"] }); + queryClient.invalidateQueries({ queryKey: ["savedHotspots"] }); + queryClient.invalidateQueries({ queryKey: ["savedPlaces"] }); + queryClient.invalidateQueries({ queryKey: ["hotspots"] }); + }, [queryClient]); + + const deleteMutation = useMutation({ + mutationFn: async (trip: Trip) => { + setBusyTripId(trip.id); + await deleteTrip(trip.id); + return trip; + }, + onSuccess: (trip) => { + invalidate(); + Toast.show({ type: "success", text1: `Removed "${trip.name}"` }); + }, + onError: (error: Error) => { + Toast.show({ type: "error", text1: error.message || "Delete failed" }); + }, + onSettled: () => setBusyTripId(null), + }); + + const refreshMutation = useMutation({ + mutationFn: async (trip: Trip) => { + if (!trip.update_token) throw new Error("NO_TOKEN"); + setBusyTripId(trip.id); + const data = await fetchBirdPlanTrip(trip.update_token); + await importTrip(data); + return data; + }, + onSuccess: (data) => { + invalidate(); + Toast.show({ type: "success", text1: `Updated "${data.name}"` }); + }, + onError: (error: unknown, trip) => { + if (error instanceof BirdPlanError && error.status === 404) { + Alert.alert( + "Trip Unavailable", + `"${trip.name}" is no longer available on BirdPlan.app. You can delete it, or re-import with a fresh code.` + ); + return; + } + if (error instanceof BirdPlanError) { + Toast.show({ type: "error", text1: error.message }); + return; + } + Toast.show({ type: "error", text1: "Update failed" }); + }, + onSettled: () => setBusyTripId(null), + }); + + const goToImport = () => router.push("/settings-birdplan-import" as Href); + + const handleUpdate = (trip: Trip) => { + if (!trip.update_token) { + Alert.alert( + "Update Trip", + `"${trip.name}" was imported before automatic updates were supported. Generate a fresh code in BirdPlan.app to re-import.`, + [ + { text: "Cancel", style: "cancel" }, + { text: "Enter Code", onPress: goToImport }, + ] + ); + return; + } + Alert.alert( + "Update Trip", + `Fetch the latest data for "${trip.name}"? Existing hotspots, pins, and notes from this trip will be replaced.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Update", + style: "destructive", + onPress: () => refreshMutation.mutate(trip), + }, + ] + ); + }; + + const handleDelete = (trip: Trip) => { + Alert.alert( + "Delete Trip", + `Remove "${trip.name}" and all hotspots, pins, and notes imported with it? This cannot be undone.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => deleteMutation.mutate(trip), + }, + ] + ); + }; + + return ( + + + + + + + + Import Trip + + + + + + router.push("/packs" as Href)} + activeOpacity={0.7} + style={tw`bg-amber-50 rounded-xl px-4 py-3 mb-6 flex-row items-center border border-amber-200`} + > + + + Hotspot packs must be installed for the areas your trips cover. + + + + + + {trips.length > 0 ? `Imported Trips (${trips.length})` : "Imported Trips"} + + + {isLoading ? ( + + + + ) : trips.length === 0 ? ( + + No trips imported yet + + ) : ( + trips.map((trip) => ( + + )) + )} + + + ); +} diff --git a/app/settings.tsx b/app/settings.tsx index c7b4e55..0ce4496 100644 --- a/app/settings.tsx +++ b/app/settings.tsx @@ -1,3 +1,4 @@ +import BirdPlanLogo from "@/components/icons/BirdPlanLogo"; import tw from "@/lib/tw"; import { getExternalMapProviders } from "@/lib/utils"; import { useSettingsStore } from "@/stores/settingsStore"; @@ -33,14 +34,19 @@ type SettingsRowProps = { onPress: () => void; isLast?: boolean; icon?: SettingsIconProps | undefined; + iconElement?: React.ReactNode; }; -function SettingsRow({ label, value, onPress, isLast, icon }: SettingsRowProps) { +function SettingsRow({ label, value, onPress, isLast, icon, iconElement }: SettingsRowProps) { const borderStyle = isLast ? {} : tw`border-b border-gray-200/50`; return ( - {icon && } + {iconElement ? ( + {iconElement} + ) : icon ? ( + + ) : null} {label} {value && {value}} @@ -181,6 +187,15 @@ export default function SettingsPage() { /> + + router.push("/settings-birdplan" as Href)} + iconElement={} + isLast + /> + + + + + + + + ); +} diff --git a/lib/birdplan.ts b/lib/birdplan.ts new file mode 100644 index 0000000..457e8a2 --- /dev/null +++ b/lib/birdplan.ts @@ -0,0 +1,37 @@ +import Constants from "expo-constants"; +import { BirdPlanTripData } from "./types"; + +const BIRDPLAN_DOMAIN = (Constants.expoConfig?.extra?.BIRDPLAN_DOMAIN as string | undefined) ?? "https://api.birdplan.app"; + +export class BirdPlanError extends Error { + constructor( + message: string, + public status: number + ) { + super(message); + this.name = "BirdPlanError"; + } +} + +export async function fetchBirdPlanTrip(codeOrToken: string): Promise { + const res = await fetch(`${BIRDPLAN_DOMAIN}/v1/trips/openbirding/${codeOrToken}`); + + if (!res.ok) { + if (res.status === 404) { + throw new BirdPlanError("Not found", 404); + } + if (res.status === 410) { + throw new BirdPlanError("This code has expired", 410); + } + if (res.status === 429) { + throw new BirdPlanError("Too many requests — try again in a moment", 429); + } + throw new BirdPlanError("Unable to reach BirdPlan. Check your connection and try again.", res.status); + } + + const data = (await res.json()) as BirdPlanTripData; + if (!data?.id || !Array.isArray(data.hotspots) || !Array.isArray(data.markers)) { + throw new BirdPlanError("Unexpected response from BirdPlan", 0); + } + return data; +} diff --git a/lib/database.ts b/lib/database.ts index 7e92e0b..f7b8ecc 100644 --- a/lib/database.ts +++ b/lib/database.ts @@ -1,5 +1,5 @@ import * as SQLite from "expo-sqlite"; -import { SavedPlace, StaticPackHotspot, StaticPackTarget } from "./types"; +import { BirdPlanTripData, SavedPlace, StaticPackHotspot, StaticPackTarget, Trip } from "./types"; let db: SQLite.SQLiteDatabase | null = null; let isInstallingPack = false; @@ -8,8 +8,8 @@ export async function initializeDatabase(): Promise { try { db = await SQLite.openDatabaseAsync("openbirding.db"); await createTables(); - await createIndexes(); await runMigrations(); + await createIndexes(); console.log("Database initialized successfully"); } catch (error) { console.error("Failed to initialize database:", error); @@ -49,12 +49,15 @@ async function createTables(): Promise { ); `); + // Foreign keys are intentionally not enforced (PRAGMA foreign_keys is OFF). + // This allows saved_hotspots to reference hotspot IDs that may not yet exist + // in the hotspots table (e.g. trip imports), and survive pack uninstalls. await db.execAsync(` CREATE TABLE IF NOT EXISTS saved_hotspots ( hotspot_id TEXT PRIMARY KEY NOT NULL, saved_at TEXT NOT NULL, notes TEXT, - FOREIGN KEY (hotspot_id) REFERENCES hotspots (id) ON DELETE CASCADE + trip_id TEXT ); `); @@ -66,7 +69,8 @@ async function createTables(): Promise { icon TEXT NOT NULL, lat REAL NOT NULL, lng REAL NOT NULL, - saved_at TEXT NOT NULL + saved_at TEXT NOT NULL, + trip_id TEXT ); `); @@ -84,9 +88,27 @@ async function createTables(): Promise { hotspot_id TEXT NOT NULL, code TEXT NOT NULL, pinned_at TEXT NOT NULL, + trip_id TEXT, PRIMARY KEY (hotspot_id, code) ); `); + + await db.execAsync(` + CREATE TABLE IF NOT EXISTS trips ( + id TEXT PRIMARY KEY NOT NULL, + type TEXT NOT NULL DEFAULT 'birdplan', + name TEXT NOT NULL, + start_month INTEGER, + end_month INTEGER, + min_lat REAL, + max_lat REAL, + min_lng REAL, + max_lng REAL, + imported_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + update_token TEXT + ); + `); } async function createIndexes(): Promise { @@ -116,6 +138,21 @@ async function createIndexes(): Promise { CREATE INDEX IF NOT EXISTS idx_targets_pack_id ON targets (pack_id); `); + + await db.execAsync(` + CREATE INDEX IF NOT EXISTS idx_saved_hotspots_trip_id + ON saved_hotspots (trip_id); + `); + + await db.execAsync(` + CREATE INDEX IF NOT EXISTS idx_saved_places_trip_id + ON saved_places (trip_id); + `); + + await db.execAsync(` + CREATE INDEX IF NOT EXISTS idx_pinned_targets_trip_id + ON pinned_targets (trip_id); + `); } async function runMigrations(): Promise { @@ -130,6 +167,34 @@ async function runMigrations(): Promise { if (!packsColumns.includes("updated_at")) { await db.execAsync(`ALTER TABLE packs ADD COLUMN updated_at TEXT`); } + + const savedHotspotsTableInfo = await db.getAllAsync<{ name: string }>("PRAGMA table_info(saved_hotspots)"); + const savedHotspotsColumns = savedHotspotsTableInfo.map((col) => col.name); + if (!savedHotspotsColumns.includes("trip_id")) { + await db.execAsync(`ALTER TABLE saved_hotspots ADD COLUMN trip_id TEXT`); + } + + const savedPlacesTableInfo = await db.getAllAsync<{ name: string }>("PRAGMA table_info(saved_places)"); + const savedPlacesColumns = savedPlacesTableInfo.map((col) => col.name); + if (!savedPlacesColumns.includes("trip_id")) { + await db.execAsync(`ALTER TABLE saved_places ADD COLUMN trip_id TEXT`); + } + + const pinnedTargetsTableInfo = await db.getAllAsync<{ name: string }>("PRAGMA table_info(pinned_targets)"); + const pinnedTargetsColumns = pinnedTargetsTableInfo.map((col) => col.name); + if (!pinnedTargetsColumns.includes("trip_id")) { + await db.execAsync(`ALTER TABLE pinned_targets ADD COLUMN trip_id TEXT`); + } + + const tripsTableInfo = await db.getAllAsync<{ name: string }>("PRAGMA table_info(trips)"); + const tripsColumns = tripsTableInfo.map((col) => col.name); + if (tripsColumns.length > 0 && !tripsColumns.includes("update_token")) { + await db.execAsync(`ALTER TABLE trips ADD COLUMN update_token TEXT`); + } + if (tripsColumns.length > 0 && !tripsColumns.includes("type")) { + await db.execAsync(`ALTER TABLE trips ADD COLUMN type TEXT NOT NULL DEFAULT 'birdplan'`); + } + } export function getDatabase(): SQLite.SQLiteDatabase { @@ -233,12 +298,15 @@ export async function getPackById(id: number): Promise<{ export async function saveHotspot(hotspotId: string, notes?: string): Promise { if (!db) throw new Error("Database not initialized"); + const existing = await db.getFirstAsync<{ trip_id: string | null }>( + `SELECT trip_id FROM saved_hotspots WHERE hotspot_id = ?`, + [hotspotId] + ); const savedAt = new Date().toISOString(); - await db.runAsync(`INSERT OR REPLACE INTO saved_hotspots (hotspot_id, saved_at, notes) VALUES (?, ?, ?)`, [ - hotspotId, - savedAt, - notes || null, - ]); + await db.runAsync( + `INSERT OR REPLACE INTO saved_hotspots (hotspot_id, saved_at, notes, trip_id) VALUES (?, ?, ?, ?)`, + [hotspotId, savedAt, notes || null, existing?.trip_id ?? null] + ); } export async function unsaveHotspot(hotspotId: string): Promise { @@ -406,10 +474,14 @@ export async function cleanupPartialInstall(packId: number): Promise { export async function savePlace({ id, name, notes, icon, lat, lng }: Omit): Promise { if (!db) throw new Error("Database not initialized"); + const existing = await db.getFirstAsync<{ trip_id: string | null }>( + `SELECT trip_id FROM saved_places WHERE id = ?`, + [id] + ); const savedAt = new Date().toISOString(); await db.runAsync( - `INSERT OR REPLACE INTO saved_places (id, name, notes, icon, lat, lng, saved_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, - [id, name, notes, icon, lat, lng, savedAt] + `INSERT OR REPLACE INTO saved_places (id, name, notes, icon, lat, lng, saved_at, trip_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [id, name, notes, icon, lat, lng, savedAt, existing?.trip_id ?? null] ); return id; @@ -625,9 +697,13 @@ export async function getPinnedTargets(hotspotId: string): Promise { export async function pinTarget(hotspotId: string, speciesCode: string): Promise { if (!db) throw new Error("Database not initialized"); + const existing = await db.getFirstAsync<{ trip_id: string | null }>( + `SELECT trip_id FROM pinned_targets WHERE hotspot_id = ? AND code = ?`, + [hotspotId, speciesCode] + ); await db.runAsync( - `INSERT OR REPLACE INTO pinned_targets (hotspot_id, code, pinned_at) VALUES (?, ?, ?)`, - [hotspotId, speciesCode, new Date().toISOString()] + `INSERT OR REPLACE INTO pinned_targets (hotspot_id, code, pinned_at, trip_id) VALUES (?, ?, ?, ?)`, + [hotspotId, speciesCode, new Date().toISOString(), existing?.trip_id ?? null] ); } @@ -635,3 +711,117 @@ export async function unpinTarget(hotspotId: string, speciesCode: string): Promi if (!db) throw new Error("Database not initialized"); await db.runAsync(`DELETE FROM pinned_targets WHERE hotspot_id = ? AND code = ?`, [hotspotId, speciesCode]); } + +export async function getTrips(): Promise { + if (!db) throw new Error("Database not initialized"); + + const rows = await db.getAllAsync( + `SELECT + t.id, t.type, t.name, t.start_month, t.end_month, + t.min_lat, t.max_lat, t.min_lng, t.max_lng, + t.imported_at, t.updated_at, t.update_token, + (SELECT COUNT(*) FROM saved_hotspots WHERE trip_id = t.id) AS hotspot_count, + (SELECT COUNT(*) FROM saved_places WHERE trip_id = t.id) AS marker_count + FROM trips t + ORDER BY t.imported_at DESC` + ); + + return rows; +} + +export async function getTripById(tripId: string): Promise { + if (!db) throw new Error("Database not initialized"); + + const row = await db.getFirstAsync( + `SELECT + t.id, t.type, t.name, t.start_month, t.end_month, + t.min_lat, t.max_lat, t.min_lng, t.max_lng, + t.imported_at, t.updated_at, t.update_token, + (SELECT COUNT(*) FROM saved_hotspots WHERE trip_id = t.id) AS hotspot_count, + (SELECT COUNT(*) FROM saved_places WHERE trip_id = t.id) AS marker_count + FROM trips t + WHERE t.id = ?`, + [tripId] + ); + + return row ?? null; +} + +export async function importTrip(data: BirdPlanTripData): Promise { + if (!db) throw new Error("Database not initialized"); + + const database = db; + const now = new Date().toISOString(); + const existing = await database.getFirstAsync<{ imported_at: string; update_token: string | null }>( + `SELECT imported_at, update_token FROM trips WHERE id = ?`, + [data.id] + ); + const importedAt = existing?.imported_at ?? now; + // On initial import we receive a fresh updateToken. Refresh responses omit it — keep the one we already have. + const updateToken = data.updateToken ?? existing?.update_token ?? null; + + await database.withTransactionAsync(async () => { + // Remove any existing trip content so we start fresh. + await deleteTripContent(database, data.id); + + await database.runAsync( + `INSERT OR REPLACE INTO trips + (id, type, name, start_month, end_month, min_lat, max_lat, min_lng, max_lng, imported_at, updated_at, update_token) + VALUES (?, 'birdplan', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + data.id, + data.name, + data.startMonth ?? null, + data.endMonth ?? null, + data.bounds?.minY ?? null, + data.bounds?.maxY ?? null, + data.bounds?.minX ?? null, + data.bounds?.maxX ?? null, + importedAt, + now, + updateToken, + ] + ); + + for (const hotspot of data.hotspots) { + await database.runAsync( + `INSERT OR REPLACE INTO saved_hotspots (hotspot_id, saved_at, notes, trip_id) VALUES (?, ?, ?, ?)`, + [hotspot.id, now, hotspot.notes?.trim() || null, data.id] + ); + + if (hotspot.favs?.length) { + for (const fav of hotspot.favs) { + await database.runAsync( + `INSERT OR REPLACE INTO pinned_targets (hotspot_id, code, pinned_at, trip_id) VALUES (?, ?, ?, ?)`, + [hotspot.id, fav.code, now, data.id] + ); + } + } + } + + for (const marker of data.markers) { + const placeId = `${data.id}_${marker.id}`; + await database.runAsync( + `INSERT OR REPLACE INTO saved_places (id, name, notes, icon, lat, lng, saved_at, trip_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [placeId, marker.name, marker.notes?.trim() || null, marker.icon, marker.lat, marker.lng, now, data.id] + ); + } + }); +} + +async function deleteTripContent(database: SQLite.SQLiteDatabase, tripId: string): Promise { + await database.runAsync(`DELETE FROM saved_places WHERE trip_id = ?`, [tripId]); + await database.runAsync(`DELETE FROM pinned_targets WHERE trip_id = ?`, [tripId]); + await database.runAsync(`DELETE FROM saved_hotspots WHERE trip_id = ?`, [tripId]); +} + +export async function deleteTrip(tripId: string): Promise { + if (!db) throw new Error("Database not initialized"); + + const database = db; + await database.withTransactionAsync(async () => { + await deleteTripContent(database, tripId); + await database.runAsync(`DELETE FROM trips WHERE id = ?`, [tripId]); + }); +} diff --git a/lib/types.ts b/lib/types.ts index c56df34..183656f 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -89,3 +89,64 @@ export type Hotspot = { species: number; country: string | null; }; + +export type TripType = "birdplan"; + +export type Trip = { + id: string; + type: TripType; + name: string; + start_month: number | null; + end_month: number | null; + min_lat: number | null; + max_lat: number | null; + min_lng: number | null; + max_lng: number | null; + imported_at: string; + updated_at: string; + update_token: string | null; + hotspot_count: number; + marker_count: number; +}; + +export type BirdPlanTripFav = { + name: string; + code: string; + range: string; + percent: number; +}; + +export type BirdPlanTripHotspot = { + id: string; + name: string; + lat: number; + lng: number; + species: number; + notes: string | null; + favs?: BirdPlanTripFav[]; +}; + +export type BirdPlanTripMarker = { + id: string; + name: string; + lat: number; + lng: number; + icon: string; + notes: string | null; +}; + +export type BirdPlanTripData = { + id: string; + name: string; + startMonth: number; + endMonth: number; + bounds: { + minX: number; + maxX: number; + minY: number; + maxY: number; + }; + hotspots: BirdPlanTripHotspot[]; + markers: BirdPlanTripMarker[]; + updateToken?: string; +};