Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f629603
Hotspot filtering prototype
rawcomposition May 29, 2026
a6c25a7
Keep existing throttled loading when not filtering
rawcomposition May 29, 2026
444df05
Merge branch 'main' into filter-by-needs-count
rawcomposition Jun 11, 2026
eef0869
Move filters UI
rawcomposition Jun 12, 2026
f653a71
Adjust target hotspots feature UI
rawcomposition Jun 12, 2026
62b18b4
Rename code references to new feature
rawcomposition Jun 12, 2026
49e4ed6
Remove logging and fix state change bug
rawcomposition Jun 12, 2026
3fbe824
Map aware list view
rawcomposition Jun 12, 2026
31e6ca7
Switch to two-button approach
rawcomposition Jun 12, 2026
3ad272e
Update MapViewControls.tsx
rawcomposition Jun 12, 2026
fa9cc91
Switch to filter icon
rawcomposition Jun 12, 2026
aa54d77
Remove centering tweaks
rawcomposition Jun 12, 2026
f96c671
Add search feature
rawcomposition Jun 12, 2026
9bd462c
Clean up searching
rawcomposition Jun 12, 2026
0284db5
Deslop comments
rawcomposition Jun 12, 2026
5432a5e
Code review fixes
rawcomposition Jun 12, 2026
0904b4b
Update and fix types
rawcomposition Jun 12, 2026
e5b5486
Update SearchSheet.tsx
rawcomposition Jun 12, 2026
d2fa83f
General cleanup
rawcomposition Jun 12, 2026
8d8a4a5
Remove scroll restoration
rawcomposition Jun 12, 2026
82f61e1
Include saved hotspot icon in lists
rawcomposition Jun 12, 2026
06fac3d
Delay loading badge display
rawcomposition Jun 12, 2026
a75515f
Increment app version
rawcomposition Jun 12, 2026
292449a
Store units in settings
rawcomposition Jun 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ module.exports = ({ config }) => ({
...(config.expo || {}),
name: "OpenBirding",
slug: "OpenBirding",
version: "1.8.0",
version: "2.0.0",
orientation: "portrait",
icon: "./assets/images/logo.png",
scheme: "openbirding",
Expand Down Expand Up @@ -58,6 +58,7 @@ module.exports = ({ config }) => ({
"expo-web-browser",
"expo-font",
"expo-image",
"expo-localization",
],
experiments: { typedRoutes: true, reactCompiler: true },
extra: {
Expand Down
9 changes: 9 additions & 0 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,15 @@ export default function RootLayout() {
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="settings-units"
options={{
title: "Distance Units",
headerBackButtonDisplayMode: "minimal",
headerStyle: { backgroundColor: "#f9fafb" },
headerShadowVisible: false,
}}
/>
<Stack.Screen
name="settings-import-life-list"
options={{
Expand Down
63 changes: 58 additions & 5 deletions app/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import CountBadge from "@/components/CountBadge";
import FilterSheet from "@/components/FilterSheet";
import FloatingButton from "@/components/FloatingButton";
import HotspotDialog from "@/components/HotspotDialog";
import HotspotList from "@/components/HotspotList";
import MapListIcon from "@/components/icons/MapListIcon";
import MapViewControls from "@/components/MapViewControls";
import Mapbox, { MapboxMapRef } from "@/components/Mapbox";
import MenuBottomSheet from "@/components/MenuBottomSheet";
import PacksNotice from "@/components/PacksNotice";
import PlaceDialog from "@/components/PlaceDialog";
import SearchSheet from "@/components/SearchSheet";
import SunIndicator from "@/components/SunIndicator";
import { useActiveFilterCount } from "@/hooks/useActiveFilterCount";
import { useInstalledPacks } from "@/hooks/useInstalledPacks";
import { usePackUpdates } from "@/hooks/usePackUpdates";
import { useSavedLocation } from "@/hooks/useSavedLocation";
Expand All @@ -20,9 +24,12 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";

export default function HomeScreen() {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isFilterSheetOpen, setIsFilterSheetOpen] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const mapRef = useRef<MapboxMapRef>(null);
const isMapTouchActiveRef = useRef(false);
const insets = useSafeAreaInsets();
const activeFilterCount = useActiveFilterCount();

const { isLoadingLocation, savedLocation, updateLocation, hadSavedLocationOnInit } = useSavedLocation();
const {
Expand All @@ -39,7 +46,7 @@ export default function HomeScreen() {
setIsMapAttributionOpen,
} = useMapStore();
const { data: installedPacks, isLoading: isLoadingInstalledPacks } = useInstalledPacks();
const { hasUpdates } = usePackUpdates();
const { updateCount } = usePackUpdates();

const handleMapPress = (_event: any) => {
if (isMenuOpen) handleCloseBottomSheet();
Expand Down Expand Up @@ -84,10 +91,26 @@ export default function HomeScreen() {
setIsHotspotListOpen(true);
};

const handleOpenFilters = () => {
setIsFilterSheetOpen(true);
};

const handleCloseFilters = () => {
setIsFilterSheetOpen(false);
};

const handleCloseHotspotList = () => {
setIsHotspotListOpen(false);
};

const handleOpenSearch = () => {
setIsSearchOpen(true);
};

const handleCloseSearch = () => {
setIsSearchOpen(false);
};

const handleMapTouchActiveChange = useCallback((isActive: boolean) => {
isMapTouchActiveRef.current = isActive;
}, []);
Expand Down Expand Up @@ -121,6 +144,18 @@ export default function HomeScreen() {
[setCustomPinCoordinates, setPlaceId, setHotspotId]
);

const handleSelectPlaceFromList = useCallback(
(selectedPlaceId: string, lat: number, lng: number) => {
setCustomPinCoordinates(null);
setHotspotId(null);
setPlaceId(selectedPlaceId);
setTimeout(() => {
mapRef.current?.centerOnCoordinates(lng, lat, 200);
}, 500);
},
[setCustomPinCoordinates, setHotspotId, setPlaceId]
);

if (isLoadingLocation) return null;

const initialCenter = savedLocation?.center ?? [-98.5, 39.5];
Expand Down Expand Up @@ -171,17 +206,34 @@ export default function HomeScreen() {
<FloatingButton onPress={handleCenterOnUser} light={currentLayer === "satellite"}>
<Ionicons name="locate" size={24} color={tw.color("gray-700")} />
</FloatingButton>
<FloatingButton onPress={handleOpenHotspotList} light={currentLayer === "satellite"}>
<MapListIcon size={24} color={tw.color("gray-700")} />
<FloatingButton onPress={handleOpenSearch} light={currentLayer === "satellite"}>
<Ionicons name="search" size={24} color={tw.color("gray-700")} />
</FloatingButton>
<View style={tw`relative`}>
<FloatingButton onPress={handleMenuPress} light={currentLayer === "satellite"}>
<Ionicons name="menu" size={24} color={tw.color("gray-700")} />
</FloatingButton>
{hasUpdates && <View style={tw`absolute top-4 right-3.5 w-2.5 h-2.5 bg-blue-500 rounded-full`} />}
<CountBadge count={updateCount} />
</View>
</View>
<View
style={[tw`absolute left-0 right-0 items-center`, { bottom: insets.bottom + 24 }]}
pointerEvents="box-none"
>
<MapViewControls
onOpenFilters={handleOpenFilters}
onOpenList={handleOpenHotspotList}
filterCount={activeFilterCount}
/>
</View>
<MenuBottomSheet isOpen={isMenuOpen} onClose={handleCloseBottomSheet} />
<FilterSheet isOpen={isFilterSheetOpen} onClose={handleCloseFilters} />
<SearchSheet
isOpen={isSearchOpen}
onClose={handleCloseSearch}
onSelectHotspot={handleSelectHotspotFromList}
onSelectPlace={handleSelectPlaceFromList}
/>
<HotspotDialog isOpen={hotspotId !== null} hotspotId={hotspotId} onClose={handleHotspotDialogClose} />
<PlaceDialog
isOpen={customPinCoordinates !== null || placeId !== null}
Expand All @@ -194,6 +246,7 @@ export default function HomeScreen() {
isOpen={isHotspotListOpen}
onClose={handleCloseHotspotList}
onSelectHotspot={handleSelectHotspotFromList}
onSelectPlace={handleSelectPlaceFromList}
/>
</View>
</GestureHandlerRootView>
Expand Down
90 changes: 90 additions & 0 deletions app/settings-units.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import tw from "@/lib/tw";
import { DistanceUnits, useSettingsStore } from "@/stores/settingsStore";
import { Ionicons } from "@expo/vector-icons";
import { GlassView, isLiquidGlassAvailable } from "expo-glass-effect";
import { useRouter } from "expo-router";
import React from "react";
import { Platform, ScrollView, Text, TouchableOpacity, View, ViewStyle } from "react-native";

type OptionRowProps = {
label: string;
selected: boolean;
onPress: () => void;
isLast?: boolean;
};

function OptionRow({ label, selected, onPress, isLast }: OptionRowProps) {
const borderStyle = isLast ? {} : tw`border-b border-gray-200/50`;

return (
<TouchableOpacity style={[tw`flex-row items-center px-4 py-3`, borderStyle]} onPress={onPress} activeOpacity={0.6}>
<Text style={tw`text-gray-900 text-base flex-1`}>{label}</Text>
{selected && <Ionicons name="checkmark" size={22} color={tw.color("blue-500")} />}
</TouchableOpacity>
);
}

type OptionsGroupProps = {
children: React.ReactNode;
footer?: string;
};

function OptionsGroup({ children, footer }: OptionsGroupProps) {
const useGlass = Platform.OS === "ios" && isLiquidGlassAvailable();

const cardStyle: ViewStyle = {
borderRadius: 12,
overflow: "hidden",
};

const content = useGlass ? (
<GlassView style={cardStyle} glassEffectStyle="regular" tintColor="rgba(255, 255, 255, 0.7)">
{children}
</GlassView>
) : (
<View style={[cardStyle, tw`bg-white`]}>{children}</View>
);

return (
<View style={tw`mb-6`}>
{content}
{footer && <Text style={tw`text-gray-500 text-xs px-4 pt-2`}>{footer}</Text>}
</View>
);
}

const OPTIONS: { value: DistanceUnits; label: string }[] = [
{ value: "metric", label: "Kilometers" },
{ value: "imperial", label: "Miles" },
];

export default function UnitsPage() {
const router = useRouter();
const distanceUnits = useSettingsStore((state) => state.distanceUnits);
const setDistanceUnits = useSettingsStore((state) => state.setDistanceUnits);

const handleSelect = (units: DistanceUnits) => {
setDistanceUnits(units);
router.back();
};

return (
<ScrollView
style={tw`flex-1 bg-gray-50`}
contentContainerStyle={tw`px-4 pt-6 pb-10`}
showsVerticalScrollIndicator={false}
>
<OptionsGroup>
{OPTIONS.map((option, index) => (
<OptionRow
key={option.value}
label={option.label}
selected={distanceUnits === option.value}
onPress={() => handleSelect(option.value)}
isLast={index === OPTIONS.length - 1}
/>
))}
</OptionsGroup>
</ScrollView>
);
}
11 changes: 9 additions & 2 deletions app/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export default function SettingsPage() {
const lifelistExclusions = useSettingsStore((state) => state.lifelistExclusions);
const disableSunTimes = useSettingsStore((state) => state.disableSunTimes);
const setDisableSunTimes = useSettingsStore((state) => state.setDisableSunTimes);
const distanceUnits = useSettingsStore((state) => state.distanceUnits);
const providers = getExternalMapProviders();

const getProviderName = (providerId: string | null) => {
Expand Down Expand Up @@ -157,6 +158,12 @@ export default function SettingsPage() {
</SettingsGroup>

<SettingsGroup header="Map Display">
<SettingsRow
label="Distance Units"
value={distanceUnits === "imperial" ? "Miles" : "Kilometers"}
onPress={() => router.push("/settings-units" as Href)}
icon={{ family: "fontawesome5", name: "ruler", bgColor: "#5856D6" }}
/>
<SettingsToggleRow
label="Show Sunrise/Sunset"
value={!disableSunTimes}
Expand All @@ -177,12 +184,12 @@ export default function SettingsPage() {
label="Life List Exclusions"
value={lifelistExclusions?.length ? `${lifelistExclusions.length} species` : undefined}
onPress={() => router.push("/settings-life-list-exclusions" as Href)}
icon={{ name: "eye-off", bgColor: "#FF9500" }}
icon={{ name: "eye-off", bgColor: "#FF3B30" }}
/>
<SettingsRow
label="Import Life List"
onPress={() => router.push("/settings-import-life-list" as Href)}
icon={{ name: "cloud-upload", bgColor: "#5856D6" }}
icon={{ name: "cloud-upload", bgColor: "#30B0C7" }}
isLast
/>
</SettingsGroup>
Expand Down
2 changes: 1 addition & 1 deletion components/ActionButtonRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export default function ActionButtonRow({ children, stacked = false }: ActionBut
{child}
</View>
));
const containerStyle = [tw`w-full mt-2`, stacked ? tw`flex-col` : tw`flex-row`, { gap: GAP }] as const;
const containerStyle = [tw`w-full mt-2`, stacked ? tw`flex-col` : tw`flex-row`, { gap: GAP }];

return useGlass ? (
<GlassContainer style={containerStyle} spacing={GAP}>
Expand Down
15 changes: 15 additions & 0 deletions components/CountBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import tw from "@/lib/tw";
import React from "react";
import { Text, View } from "react-native";

export default function CountBadge({ count }: { count: number }) {
if (count <= 0) return null;

return (
<View
style={tw`absolute -top-1 -right-1 min-w-5 h-5 bg-blue-500 rounded-full items-center justify-center px-1.5 border-2 border-white`}
>
<Text style={tw`text-white text-xs font-bold`}>{count}</Text>
</View>
);
}
26 changes: 0 additions & 26 deletions components/FilterSection.tsx

This file was deleted.

31 changes: 31 additions & 0 deletions components/FilterSheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import tw from "@/lib/tw";
import { useFiltersStore } from "@/stores/filtersStore";
import { useSettingsStore } from "@/stores/settingsStore";
import React from "react";
import { Switch, Text, View } from "react-native";
import BaseBottomSheet from "./BaseBottomSheet";
import TargetRichHotspotControls from "./TargetRichHotspotControls";

type FilterSheetProps = {
isOpen: boolean;
onClose: () => void;
};

export default function FilterSheet({ isOpen, onClose }: FilterSheetProps) {
const showSavedOnly = useFiltersStore((state) => state.showSavedOnly);
const setShowSavedOnly = useFiltersStore((state) => state.setShowSavedOnly);
const lifelist = useSettingsStore((state) => state.lifelist);
const hasLifeList = (lifelist?.length ?? 0) > 0;

return (
<BaseBottomSheet isOpen={isOpen} onClose={onClose} title="Filters" detents={["auto"]} dimmed>
<View style={tw`px-6 pt-4 gap-4 pb-2`}>
<View style={tw`flex-row items-center justify-between`}>
<Text style={tw`text-base font-medium text-gray-900`}>Show saved only</Text>
<Switch value={showSavedOnly} onValueChange={setShowSavedOnly} />
</View>
<TargetRichHotspotControls hasLifeList={hasLifeList} />
</View>
</BaseBottomSheet>
);
}
2 changes: 1 addition & 1 deletion components/HotspotDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ function HotspotDialogContent({ isOpen, hotspotId, onClose }: HotspotDialogProps

const handleViewDetails = () => {
if (!hotspot) return;
const url = `https://ebird.org/hotspot/${hotspot.id}`;
const url = `https://ebird.org/hotspot/${hotspot.id}/about`;
Linking.openURL(url).catch(() => {
Alert.alert("Error", "Could not open eBird hotspot page");
});
Expand Down
Loading