From 3959aa2f72cb9e782434602e9467f9468599fcf2 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 9 Jan 2026 07:15:53 +0100 Subject: [PATCH] fix(android): convert intro modal to bottom sheet bc broken on smaller screens --- app/(auth)/(tabs)/(home)/_layout.tsx | 29 +-- app/(auth)/(tabs)/(home)/intro/page.tsx | 154 ------------- .../(tabs)/(home)/settings/intro/page.tsx | 6 +- app/(auth)/(tabs)/_layout.tsx | 9 +- app/_layout.tsx | 105 ++++----- components/IntroSheet.tsx | 203 ++++++++++++++++++ providers/IntroSheetProvider.tsx | 55 +++++ 7 files changed, 321 insertions(+), 240 deletions(-) delete mode 100644 app/(auth)/(tabs)/(home)/intro/page.tsx create mode 100644 components/IntroSheet.tsx create mode 100644 providers/IntroSheetProvider.tsx diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx index 7c9555d2..dcdb94f2 100644 --- a/app/(auth)/(tabs)/(home)/_layout.tsx +++ b/app/(auth)/(tabs)/(home)/_layout.tsx @@ -1,7 +1,7 @@ import { Feather, Ionicons } from "@expo/vector-icons"; import { Stack, useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; -import { Platform, Text, TouchableOpacity, View } from "react-native"; +import { Platform, TouchableOpacity, View } from "react-native"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; const Chromecast = Platform.isTV ? null : require("@/components/Chromecast"); @@ -328,33 +328,6 @@ export default function IndexLayout() { ), }} /> - null, - headerRight: () => ( - _router.back()} - style={{ - marginRight: Platform.OS === "android" ? 16 : 0, - paddingHorizontal: 6, - }} - > - - {t("home.intro.done_button")} - - - ), - presentation: "modal", - }} - /> {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => ( ))} diff --git a/app/(auth)/(tabs)/(home)/intro/page.tsx b/app/(auth)/(tabs)/(home)/intro/page.tsx deleted file mode 100644 index 572a4980..00000000 --- a/app/(auth)/(tabs)/(home)/intro/page.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { Feather, Ionicons } from "@expo/vector-icons"; -import { Image } from "expo-image"; -import { useFocusEffect, useRouter } from "expo-router"; -import { useCallback } from "react"; -import { useTranslation } from "react-i18next"; -import { Linking, Platform, TouchableOpacity, View } from "react-native"; -import { Button } from "@/components/Button"; -import { Text } from "@/components/common/Text"; -import { storage } from "@/utils/mmkv"; - -export default function page() { - const router = useRouter(); - const { t } = useTranslation(); - - useFocusEffect( - useCallback(() => { - storage.set("hasShownIntro", true); - }, []), - ); - - return ( - - - - {t("home.intro.welcome_to_streamyfin")} - - - {t("home.intro.a_free_and_open_source_client_for_jellyfin")} - - - - - - {t("home.intro.features_title")} - - {t("home.intro.features_description")} - - - - Jellyseerr - - {t("home.intro.jellyseerr_feature_description")} - - - - {!Platform.isTV && ( - <> - - - - - - - {t("home.intro.downloads_feature_title")} - - - {t("home.intro.downloads_feature_description")} - - - - - - - - - Chromecast - - {t("home.intro.chromecast_feature_description")} - - - - - )} - - - - - - - {t("home.intro.centralised_settings_plugin_title")} - - - - {t("home.intro.centralised_settings_plugin_description")}{" "} - - { - Linking.openURL( - "https://github.com/streamyfin/jellyfin-plugin-streamyfin", - ); - }} - > - - {t("home.intro.read_more")} - - - - - - - - - { - router.back(); - router.push("/settings"); - }} - className='mt-4' - > - - {t("home.intro.go_to_settings_button")} - - - - - ); -} diff --git a/app/(auth)/(tabs)/(home)/settings/intro/page.tsx b/app/(auth)/(tabs)/(home)/settings/intro/page.tsx index 4cb43e6d..6e04777b 100644 --- a/app/(auth)/(tabs)/(home)/settings/intro/page.tsx +++ b/app/(auth)/(tabs)/(home)/settings/intro/page.tsx @@ -1,13 +1,13 @@ -import { useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ListGroup } from "@/components/list/ListGroup"; import { ListItem } from "@/components/list/ListItem"; +import { useIntroSheet } from "@/providers/IntroSheetProvider"; import { storage } from "@/utils/mmkv"; export default function IntroPage() { - const router = useRouter(); + const { showIntro } = useIntroSheet(); const insets = useSafeAreaInsets(); const { t } = useTranslation(); @@ -26,7 +26,7 @@ export default function IntroPage() { { - router.push("/intro/page"); + showIntro(); }} title={t("home.settings.intro.show_intro")} /> diff --git a/app/(auth)/(tabs)/_layout.tsx b/app/(auth)/(tabs)/_layout.tsx index 25ebdf35..f48a973b 100644 --- a/app/(auth)/(tabs)/_layout.tsx +++ b/app/(auth)/(tabs)/_layout.tsx @@ -7,7 +7,7 @@ import type { ParamListBase, TabNavigationState, } from "@react-navigation/native"; -import { useFocusEffect, useRouter, withLayoutContext } from "expo-router"; +import { useFocusEffect, withLayoutContext } from "expo-router"; import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Platform, View } from "react-native"; @@ -15,6 +15,7 @@ import { SystemBars } from "react-native-edge-to-edge"; import { MiniPlayerBar } from "@/components/music/MiniPlayerBar"; import { MusicPlaybackEngine } from "@/components/music/MusicPlaybackEngine"; import { Colors } from "@/constants/Colors"; +import { useIntroSheet } from "@/providers/IntroSheetProvider"; import { useSettings } from "@/utils/atoms/settings"; import { eventBus } from "@/utils/eventBus"; import { storage } from "@/utils/mmkv"; @@ -31,21 +32,21 @@ export const NativeTabs = withLayoutContext< export default function TabLayout() { const { settings } = useSettings(); const { t } = useTranslation(); - const router = useRouter(); + const { showIntro } = useIntroSheet(); useFocusEffect( useCallback(() => { const hasShownIntro = storage.getBoolean("hasShownIntro"); if (!hasShownIntro) { const timer = setTimeout(() => { - router.push("/intro/page"); + showIntro(); }, 1000); return () => { clearTimeout(timer); }; } - }, []), + }, [showIntro]), ); return ( diff --git a/app/_layout.tsx b/app/_layout.tsx index 13c0b57d..9d13871e 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -13,6 +13,7 @@ import { GlobalModal } from "@/components/GlobalModal"; import i18n from "@/i18n"; import { DownloadProvider } from "@/providers/DownloadProvider"; import { GlobalModalProvider } from "@/providers/GlobalModalProvider"; +import { IntroSheetProvider } from "@/providers/IntroSheetProvider"; import { apiAtom, getOrSetDeviceId, @@ -391,59 +392,61 @@ function Layout() { - - + + + diff --git a/components/IntroSheet.tsx b/components/IntroSheet.tsx new file mode 100644 index 00000000..6cd77a7b --- /dev/null +++ b/components/IntroSheet.tsx @@ -0,0 +1,203 @@ +import { Feather, Ionicons } from "@expo/vector-icons"; +import { + BottomSheetBackdrop, + type BottomSheetBackdropProps, + BottomSheetModal, + BottomSheetScrollView, +} from "@gorhom/bottom-sheet"; +import { Image } from "expo-image"; +import { useRouter } from "expo-router"; +import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { Linking, Platform, TouchableOpacity, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Button } from "@/components/Button"; +import { Text } from "@/components/common/Text"; +import { storage } from "@/utils/mmkv"; + +export interface IntroSheetRef { + present: () => void; + dismiss: () => void; +} + +export const IntroSheet = forwardRef((_, ref) => { + const bottomSheetRef = useRef(null); + const router = useRouter(); + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + + useImperativeHandle(ref, () => ({ + present: () => { + storage.set("hasShownIntro", true); + bottomSheetRef.current?.present(); + }, + dismiss: () => { + bottomSheetRef.current?.dismiss(); + }, + })); + + const renderBackdrop = useCallback( + (props: BottomSheetBackdropProps) => ( + + ), + [], + ); + + const handleDismiss = useCallback(() => { + bottomSheetRef.current?.dismiss(); + }, []); + + const handleGoToSettings = useCallback(() => { + bottomSheetRef.current?.dismiss(); + router.push("/settings"); + }, [router]); + + return ( + + + + + + {t("home.intro.welcome_to_streamyfin")} + + + {t("home.intro.a_free_and_open_source_client_for_jellyfin")} + + + + + + {t("home.intro.features_title")} + + + {t("home.intro.features_description")} + + + + + Jellyseerr + + {t("home.intro.jellyseerr_feature_description")} + + + + {!Platform.isTV && ( + <> + + + + + + + {t("home.intro.downloads_feature_title")} + + + {t("home.intro.downloads_feature_description")} + + + + + + + + + Chromecast + + {t("home.intro.chromecast_feature_description")} + + + + + )} + + + + + + + {t("home.intro.centralised_settings_plugin_title")} + + + + {t( + "home.intro.centralised_settings_plugin_description", + )}{" "} + + { + Linking.openURL( + "https://github.com/streamyfin/jellyfin-plugin-streamyfin", + ); + }} + > + + {t("home.intro.read_more")} + + + + + + + + + + + + {t("home.intro.go_to_settings_button")} + + + + + + + + + ); +}); + +IntroSheet.displayName = "IntroSheet"; diff --git a/providers/IntroSheetProvider.tsx b/providers/IntroSheetProvider.tsx new file mode 100644 index 00000000..c385c617 --- /dev/null +++ b/providers/IntroSheetProvider.tsx @@ -0,0 +1,55 @@ +import React, { + createContext, + type ReactNode, + useCallback, + useContext, + useRef, +} from "react"; +import { IntroSheet, type IntroSheetRef } from "@/components/IntroSheet"; + +interface IntroSheetContextType { + showIntro: () => void; + hideIntro: () => void; +} + +const IntroSheetContext = createContext( + undefined, +); + +export const useIntroSheet = () => { + const context = useContext(IntroSheetContext); + if (!context) { + throw new Error("useIntroSheet must be used within IntroSheetProvider"); + } + return context; +}; + +interface IntroSheetProviderProps { + children: ReactNode; +} + +export const IntroSheetProvider: React.FC = ({ + children, +}) => { + const sheetRef = useRef(null); + + const showIntro = useCallback(() => { + sheetRef.current?.present(); + }, []); + + const hideIntro = useCallback(() => { + sheetRef.current?.dismiss(); + }, []); + + const value = { + showIntro, + hideIntro, + }; + + return ( + + {children} + + + ); +};