feat(mobile): implement onboarding screen (#3029)

* feat(mobile): implement onboarding screen

* refactor(mobile): extract avatar setting functionality into a separate utility function

* feat(mobile): integrate  StepInterests into onboarding

* feat(mobile): add preset feeds configuration for onboarding

* feat(mobile): add onboarding completion and welcome steps

* chore: tweak styles

* fix: icon

* chore: add personalization prompt to onboarding preferences

* chore: enhance onboarding with improved layout

* chore: clean code

* refactor: compatible with modal for EditProfileModal
This commit is contained in:
Whitewater 2025-03-13 18:15:54 +08:00 committed by GitHub
parent d54535dc45
commit 2d92a4f59d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 799 additions and 55 deletions

View File

@ -0,0 +1,26 @@
import * as React from "react"
import Svg, { Path } from "react-native-svg"
interface ListCheck2CuteReIconProps {
width?: number
height?: number
color?: string
}
export const ListCheck2CuteReIcon = ({
width = 24,
height = 24,
color = "#10161F",
}: ListCheck2CuteReIconProps) => {
return (
<Svg width={width} height={height} fill="none" viewBox="0 0 24 24">
<Path
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5h9m-9 7h9m-9 7h9M7.945 3.72c-.941.725-1.754 1.53-2.475 2.475A4.225 4.225 0 0 0 4.056 4.78m0 7c.592.373 1.05.818 1.414 1.415a13.22 13.22 0 0 1 2.475-2.475m-3.89 8.06c.593.373 1.051.817 1.415 1.415a13.22 13.22 0 0 1 2.475-2.475"
/>
</Svg>
)
}

View File

@ -0,0 +1,31 @@
import * as React from "react"
import Svg, { Path } from "react-native-svg"
interface Shuffle2CuteReIconProps {
width?: number
height?: number
color?: string
}
export const Shuffle2CuteReIcon = ({
width = 24,
height = 24,
color = "#10161F",
}: Shuffle2CuteReIconProps) => {
return (
<Svg width={width} height={height} fill="none" viewBox="0 0 24 24">
<Path
stroke={color}
strokeLinecap="round"
strokeWidth={2}
d="M4 7v0c1.69 0 2.535 0 3.273.308a4 4 0 0 1 .837.473c.644.475 1.078 1.2 1.948 2.649l1.884 3.14c.87 1.45 1.304 2.174 1.948 2.649.259.19.54.35.836.473C15.466 17 16.31 17 18 17v0m0-10h-1.084c-.632 0-.948 0-1.241.044a4 4 0 0 0-2.5 1.415c-.188.229-.35.5-.675 1.041v0M4 17h1.085c.631 0 .947 0 1.24-.044a4 4 0 0 0 2.5-1.415c.188-.229.35-.5.675-1.041"
/>
<Path
fill={color}
fillRule="evenodd"
d="M17.847 4.507c-.503-.263-1.102.127-1.15.748-.032.407-.064.994-.064 1.708 0 .748.035 1.357.068 1.764.048.58.592.914 1.09.654.33-.171.806-.438 1.376-.808.57-.37 1.013-.701 1.31-.936.448-.355.465-1.051.042-1.388a17.102 17.102 0 0 0-1.325-.95c-.55-.358-1.019-.62-1.347-.792M17.847 14.617c-.503-.263-1.102.127-1.15.748-.032.406-.064.994-.064 1.708 0 .747.035 1.357.068 1.764.048.58.592.914 1.09.654.33-.171.806-.438 1.376-.808.57-.37 1.013-.701 1.31-.936.448-.355.465-1.051.042-1.388a17.13 17.13 0 0 0-1.325-.95c-.55-.358-1.019-.62-1.347-.792"
clipRule="evenodd"
/>
</Svg>
)
}

View File

@ -0,0 +1,41 @@
import { setGeneralSetting, useGeneralSettingKey } from "@/src/atoms/settings/general"
type ReadingBehavior = "radical" | "balanced" | "conservative"
export const useReadingBehavior = () => {
const markAsReadWhenScrolling = useGeneralSettingKey("scrollMarkUnread")
const markAsReadWhenInView = useGeneralSettingKey("renderMarkUnread")
const behavior: ReadingBehavior =
markAsReadWhenInView && markAsReadWhenScrolling
? "radical"
: !markAsReadWhenInView && !markAsReadWhenScrolling
? "conservative"
: "balanced"
const updateSettings = (behavior: ReadingBehavior) => {
switch (behavior) {
case "radical": {
setGeneralSetting("scrollMarkUnread", true)
setGeneralSetting("renderMarkUnread", true)
break
}
case "balanced": {
setGeneralSetting("scrollMarkUnread", true)
setGeneralSetting("renderMarkUnread", false)
break
}
case "conservative": {
setGeneralSetting("scrollMarkUnread", false)
setGeneralSetting("renderMarkUnread", false)
break
}
}
}
return {
behavior,
markAsReadWhenScrolling,
markAsReadWhenInView,
updateSettings,
}
}

View File

@ -0,0 +1,122 @@
import { FeedViewType } from "@follow/constants"
export type PresetFeedConfig = {
title: string
feedId: string
url: string
view: FeedViewType
}
export const presetFeeds: PresetFeedConfig[] = [
{
feedId: "41358761177015296",
title: "知乎热榜 - 全站",
url: "rsshub://zhihu/hot/total",
view: FeedViewType.Articles,
},
{
feedId: "100020530265058357",
title: "阮一峰的网络日志",
url: "https://feeds.feedburner.com/ruanyifeng",
view: FeedViewType.Articles,
},
{
feedId: "41358830592746496",
title: "微博热搜榜",
url: "rsshub://weibo/search/hot",
view: FeedViewType.SocialMedia,
},
{
feedId: "100411504863520768",
title: "Twitter @Elon Musk",
url: "rsshub://twitter/user/elonmusk",
view: FeedViewType.SocialMedia,
},
{
feedId: "41324816676184077",
title: "Twitter @DIŸgöd ☀️",
url: "rsshub://twitter/user/DIYgod",
view: FeedViewType.SocialMedia,
},
{
feedId: "78806242632741888",
title: "bilibili 排行榜-全站",
url: "rsshub://bilibili/ranking/0",
view: FeedViewType.Videos,
},
{
feedId: "60338304723722240",
title: "实时财经快讯 - FastBull",
url: "rsshub://fastbull/express-news",
view: FeedViewType.Articles,
},
{
feedId: "55611390687386624",
title: "格隆汇快讯-7x24小时市场快讯-财经市场热点",
url: "rsshub://gelonghui/live",
view: FeedViewType.Articles,
},
{
feedId: "49375919416104960",
title: "深潮TechFlow - 快讯",
url: "rsshub://techflowpost/express",
view: FeedViewType.Articles,
},
{
feedId: "55982073122828305",
title: "TED Talks Daily",
url: "https://feeds.acast.com/public/shows/67587e77c705e441797aff96",
view: FeedViewType.Articles,
},
{
feedId: "72541715399995392",
title: "TheBlockBeats - 快讯",
url: "rsshub://theblockbeats/newsflash/0",
view: FeedViewType.Articles,
},
{
feedId: "100184911354754055",
title: "小Lin说",
url: "https://www.youtube.com/feeds/videos.xml?channel_id=UCilwQlk62k1z7aUEZPOB6yw",
view: FeedViewType.Videos,
},
{
feedId: "100185810923910148",
title: "张小珺Jùn商业访谈录",
url: "https://feed.xyzfm.space/dk4yh3pkpjp3",
view: FeedViewType.Articles,
},
{
feedId: "56584656988676096",
title: "迷因电波",
url: "rsshub://xiaoyuzhou/podcast/61d52b3bee197a3aac3dac44",
view: FeedViewType.Articles,
},
{
feedId: "76051724651752448",
title: "极致音乐汇 的 bilibili 空间",
url: "rsshub://bilibili/user/video/1691501735",
view: FeedViewType.Videos,
},
{
feedId: "66701376672681984",
title: "AP Top News - AP News",
url: "rsshub://apnews/api/apf-topnews",
view: FeedViewType.Articles,
},
{
feedId: "44366244616936448",
title: "金十数据",
url: "rsshub://jin10",
view: FeedViewType.Articles,
},
{
feedId: "52325519371718656",
title: "Hacker News",
url: "rsshub://hackernews",
view: FeedViewType.Articles,
},
]

View File

@ -0,0 +1,13 @@
import { Text, View } from "react-native"
import { Logo } from "@/src/components/ui/logo"
export const StepFinished = () => (
<View className="flex-1 items-center justify-center">
<Logo width={80} height={80} />
<Text className="text-text my-4 text-3xl font-bold">You're all set!</Text>
<Text className="text-label mb-8 px-6 text-center text-lg">
You have completed the guide. Enjoy your journey!
</Text>
</View>
)

View File

@ -0,0 +1,110 @@
import { cn } from "@follow/utils"
import { useCallback, useState } from "react"
import { Text, TouchableOpacity, View } from "react-native"
import Animated, { FadeIn, FadeOut } from "react-native-reanimated"
import { Search3CuteReIcon } from "@/src/icons/search_3_cute_re"
import { Shuffle2CuteReIcon } from "@/src/icons/shuffle_2_cute_re"
import { toast } from "@/src/lib/toast"
import { useSubscription } from "@/src/store/subscription/hooks"
import { subscriptionSyncService } from "@/src/store/subscription/store"
import { accentColor } from "@/src/theme/colors"
import type { PresetFeedConfig } from "./preset"
import { presetFeeds } from "./preset"
const subscribeFeed = async (config: PresetFeedConfig) => {
await subscriptionSyncService.subscribe({
feedId: config.feedId,
title: config.title,
url: config.url,
view: config.view,
category: "",
isPrivate: false,
})
toast.success(`Subscribed to ${config.title}`, {
position: "bottom",
})
}
const unsubscribeFeed = async (feedId: string) => {
await subscriptionSyncService.unsubscribe(feedId)
toast.success(`Unsubscribed from feed`, {
position: "bottom",
})
}
export const StepInterests = () => {
const [displayFeeds, setDisplayFeeds] = useState<PresetFeedConfig[]>(presetFeeds.slice(0, 7))
const shuffleFeeds = useCallback(() => {
const shuffled = [...presetFeeds].sort(() => Math.random() - 0.5).slice(0, 7)
setDisplayFeeds(shuffled)
}, [])
return (
<View className="mt-[10vh] flex-1 items-center">
<View className="mb-10 flex items-center gap-4">
<Search3CuteReIcon height={80} width={80} color={accentColor} />
<Text className="text-text mt-2 text-2xl font-bold">Discover Interests</Text>
<Text className="text-label mb-8 px-6 text-center text-lg">
Subscribe to feeds that match your interests.
</Text>
</View>
<View className="w-full items-center gap-4">
<View className="flex flex-row">
<Text className="mr-2 text-base">Suggestions feed</Text>
<TouchableOpacity
onPress={shuffleFeeds}
className="bg-accent/10 flex-row items-center rounded-full px-3 py-1"
>
<Shuffle2CuteReIcon height={16} width={16} color={accentColor} />
<Text className="text-accent ml-1 text-sm">Shuffle</Text>
</TouchableOpacity>
</View>
<View className="flex-row flex-wrap justify-center gap-2 px-4">
{displayFeeds.map((feed) => (
<FeedChip key={feed.feedId} {...feed} />
))}
</View>
</View>
</View>
)
}
const FeedChip = (feed: PresetFeedConfig) => {
const isSubscribed = useSubscription(feed.feedId)
const handleSubscribe = useCallback(
async (feed: PresetFeedConfig) => {
if (isSubscribed) {
await unsubscribeFeed(feed.feedId)
return
}
await subscribeFeed(feed)
},
[isSubscribed],
)
return (
<Animated.View
key={feed.feedId}
entering={FadeIn.duration(300)}
exiting={FadeOut.duration(300)}
>
<TouchableOpacity
onPress={() => handleSubscribe(feed)}
className={cn(
"flex rounded-full px-4 py-2",
isSubscribed ? "bg-accent" : "bg-secondary-system-fill",
)}
>
<Text className={`text-center text-sm ${isSubscribed ? "text-white" : "text-label"}`}>
{feed.title}
</Text>
</TouchableOpacity>
</Animated.View>
)
}

View File

@ -0,0 +1,151 @@
import { router } from "expo-router"
import type { PropsWithChildren } from "react"
import { ActivityIndicator, Pressable, Text, TouchableOpacity, View } from "react-native"
import { useColor } from "react-native-uikit-colors"
import { UserAvatar } from "@/src/components/ui/avatar/UserAvatar"
import { GroupedInsetListNavigationLinkIcon } from "@/src/components/ui/grouped/GroupedList"
import { DocmentCuteReIcon } from "@/src/icons/docment_cute_re"
import { FileImportCuteReIcon } from "@/src/icons/file_import_cute_re"
import { ListCheck2CuteReIcon } from "@/src/icons/list_check_2_cute_re"
import { MingcuteRightLine } from "@/src/icons/mingcute_right_line"
import { Settings1CuteReIcon } from "@/src/icons/settings_1_cute_re"
import { useWhoami } from "@/src/store/user/hooks"
import { accentColor } from "@/src/theme/colors"
import { importOpml, setAvatar } from "../settings/utils"
import { useReadingBehavior } from "./hooks/use-reading-behavior"
export const StepPreferences = () => {
const { behavior } = useReadingBehavior()
return (
<View className="mt-[10vh] flex-1 p-4">
<View className="mb-10 flex items-center gap-4">
<ListCheck2CuteReIcon height={80} width={80} color={accentColor} />
<Text className="text-text mt-2 text-center text-xl font-bold">
Personalize Your Experience
</Text>
<Text className="text-label text-center text-base">
Set your preferences to make Follow work best for you. You can always change these later
in Settings.
</Text>
</View>
<View className="mb-6 gap-4">
<PreferenceCard
title="Edit Profile"
icon={
<GroupedInsetListNavigationLinkIcon backgroundColor="#34D399">
<Settings1CuteReIcon color="#fff" width={40} height={40} />
</GroupedInsetListNavigationLinkIcon>
}
onPress={() => {
router.push("/onboarding/edit-profile")
}}
>
<Text className="text-secondary-label text-sm">
Change your name, email, and profile picture
</Text>
</PreferenceCard>
{/* Reading Preferences Card */}
<PreferenceCard
title="Reading Preferences"
icon={
<GroupedInsetListNavigationLinkIcon backgroundColor="#F59E0B">
<DocmentCuteReIcon color="#fff" width={40} height={40} />
</GroupedInsetListNavigationLinkIcon>
}
onPress={() => {
router.push("/onboarding/select-reading-mode")
}}
>
{behavior === "radical" && (
<Text className="text-secondary-label text-sm">
Automatically mark entries as read when displayed
</Text>
)}
{behavior === "balanced" && (
<Text className="text-secondary-label text-sm">
Automatically mark entries as read when scrolled out of view
</Text>
)}
{behavior === "conservative" && (
<Text className="text-secondary-label text-sm">
Mark entries as read only when clicked
</Text>
)}
</PreferenceCard>
{/* Import Card */}
<PreferenceCard
title="Import Your Content"
icon={
<GroupedInsetListNavigationLinkIcon backgroundColor="#CBAD6D">
<FileImportCuteReIcon color="#fff" width={40} height={40} />
</GroupedInsetListNavigationLinkIcon>
}
onPress={importOpml}
>
<View className="flex-row">
<Text className="text-secondary-label flex-1">
If you have used RSS before, you can import an OPML file
</Text>
</View>
</PreferenceCard>
</View>
</View>
)
}
export const EditProfileSection = () => {
const whoami = useWhoami()
if (!whoami) {
return (
<View className="flex-1 items-center justify-center">
<ActivityIndicator />
</View>
)
}
return (
<View className="flex-1 items-center justify-center">
<UserAvatar
image={whoami?.image}
name={whoami?.name}
size={80}
className={!whoami?.name || !whoami.image ? "bg-system-background" : ""}
/>
<TouchableOpacity className="mt-2" hitSlop={10} onPress={setAvatar}>
<Text className="text-accent text-lg">Set Avatar</Text>
</TouchableOpacity>
</View>
)
}
type PreferenceCardProps = PropsWithChildren<{
title: string
icon?: React.ReactNode
onPress?: () => void
}>
const PreferenceCard = ({ title, children, onPress, icon }: PreferenceCardProps) => {
const rightIconColor = useColor("tertiaryLabel")
return (
<Pressable
className="bg-secondary-system-grouped-background flex flex-row items-center gap-2 rounded-xl p-4"
onPress={onPress}
>
{icon}
<View className="flex flex-1 flex-col gap-2">
<Text className="text-text text-base font-medium">{title}</Text>
{children}
</View>
<MingcuteRightLine height={18} width={18} color={rightIconColor} />
</Pressable>
)
}

View File

@ -0,0 +1,13 @@
import { Text, View } from "react-native"
import { Logo } from "@/src/components/ui/logo"
export const StepWelcome = () => (
<View className="flex-1 items-center justify-center">
<Logo width={80} height={80} />
<Text className="text-text my-4 text-3xl font-bold">Welcome to Follow!</Text>
<Text className="text-label mb-8 px-6 text-center text-lg">
This guide will help you get started with the app.
</Text>
</View>
)

View File

@ -2,7 +2,7 @@ import { withOpacity } from "@follow/utils/src/color"
import { useMutation } from "@tanstack/react-query"
import { router } from "expo-router"
import type { FC } from "react"
import { useState } from "react"
import { useCallback, useState } from "react"
import {
ActivityIndicator,
Text,
@ -13,6 +13,8 @@ import {
import { KeyboardController } from "react-native-keyboard-controller"
import { RotateableLoading } from "@/src/components/common/RotateableLoading"
import { ModalHeader } from "@/src/components/layouts/header/ModalHeader"
import { SafeModalScrollView } from "@/src/components/layouts/views/SafeModalScrollView"
import {
NavigationBlurEffectHeader,
SafeNavigationScrollView,
@ -29,8 +31,6 @@ import {
import { CheckCircleCuteReIcon } from "@/src/icons/check_circle_cute_re"
import { CheckLineIcon } from "@/src/icons/check_line"
import { CloseCircleFillIcon } from "@/src/icons/close_circle_fill"
import { apiClient, apiFetch, getBizFetchErrorMessage } from "@/src/lib/api-fetch"
import { pickImage } from "@/src/lib/native/picker"
import { toast } from "@/src/lib/toast"
import { useWhoami } from "@/src/store/user/hooks"
import type { MeModel } from "@/src/store/user/store"
@ -38,6 +38,8 @@ import { userSyncService } from "@/src/store/user/store"
import type { UserProfileEditable } from "@/src/store/user/types"
import { accentColor, useColor } from "@/src/theme/colors"
import { setAvatar } from "../utils"
export const EditProfileScreen = () => {
const whoami = useWhoami()
@ -57,6 +59,26 @@ export const EditProfileScreen = () => {
)
}
export const EditProfileModal = () => {
const whoami = useWhoami()
if (!whoami) {
return (
<View className="flex-1 items-center justify-center">
<ActivityIndicator />
</View>
)
}
return (
<SafeModalScrollView className="bg-system-grouped-background">
<ModalHeader headerTitle="111Edit Profile" />
<AvatarSection whoami={whoami} />
<ProfileForm layout="modal" whoami={whoami} />
</SafeModalScrollView>
)
}
const AvatarSection: FC<{
whoami: MeModel
}> = ({ whoami }) => {
@ -69,37 +91,7 @@ const AvatarSection: FC<{
className={!whoami?.name || !whoami.image ? "bg-system-background" : ""}
/>
<TouchableOpacity
className="mt-2"
hitSlop={10}
onPress={async () => {
const result = await pickImage({
fileName: "avatar.jpg",
maxSizeKB: 290,
})
if (!result) return
const { formData } = result
const res = await apiFetch<{
url: string
}>(apiClient.upload.avatar.$url().toString(), {
method: "POST",
headers: {
"Content-Type": "multipart/form-data",
},
body: formData,
}).catch((err) => {
toast.error(getBizFetchErrorMessage(err))
throw err
})
const { url } = res
userSyncService.updateProfile({
image: url,
})
}}
>
<TouchableOpacity className="mt-2" hitSlop={10} onPress={setAvatar}>
<Text className="text-accent text-lg">Set Avatar</Text>
</TouchableOpacity>
</View>
@ -108,7 +100,8 @@ const AvatarSection: FC<{
const ProfileForm: FC<{
whoami: MeModel
}> = ({ whoami }) => {
layout?: "modal" | "screen"
}> = ({ whoami, layout = "screen" }) => {
const [dirtyFields, setDirtyFields] = useState<Partial<UserProfileEditable>>({})
const { mutateAsync: updateProfile, isPending } = useMutation({
@ -124,27 +117,36 @@ const ProfileForm: FC<{
})
const label = useColor("label")
const headerRight = useCallback(
() => (
<UIBarButton
label="Save"
disabled={isPending || Object.keys(dirtyFields).length === 0}
normalIcon={
isPending ? (
<RotateableLoading size={20} color={withOpacity(label, 0.5)} />
) : (
<CheckLineIcon height={20} width={20} />
)
}
onPress={() => {
updateProfile()
}}
/>
),
[dirtyFields, isPending, label, updateProfile],
)
const Header =
layout === "modal" ? (
<ModalHeader headerRight={headerRight()} headerTitle="Edit Profile" />
) : (
<NavigationBlurEffectHeader headerRight={headerRight} title="Edit Profile" />
)
return (
<View className="mt-4">
<NavigationBlurEffectHeader
headerRight={() => (
<UIBarButton
label="Save"
disabled={isPending || Object.keys(dirtyFields).length === 0}
normalIcon={
isPending ? (
<RotateableLoading size={20} color={withOpacity(label, 0.5)} />
) : (
<CheckLineIcon height={20} width={20} />
)
}
onPress={() => {
updateProfile()
}}
/>
)}
title="Edit Profile"
/>
{Header}
<TouchableWithoutFeedback
onPress={() => {

View File

@ -3,8 +3,38 @@ import * as FileSystem from "expo-file-system"
import * as Sharing from "expo-sharing"
import { getDbPath } from "@/src/database"
import { apiFetch, getBizFetchErrorMessage } from "@/src/lib/api-fetch"
import { apiClient, apiFetch, getBizFetchErrorMessage } from "@/src/lib/api-fetch"
import { pickImage } from "@/src/lib/native/picker"
import { toast } from "@/src/lib/toast"
import { userSyncService } from "@/src/store/user/store"
export const setAvatar = async () => {
const result = await pickImage({
fileName: "avatar.jpg",
maxSizeKB: 290,
})
if (!result) return
const { formData } = result
const res = await apiFetch<{
url: string
}>(apiClient.upload.avatar.$url().toString(), {
method: "POST",
headers: {
"Content-Type": "multipart/form-data",
},
body: formData,
}).catch((err) => {
toast.error(getBizFetchErrorMessage(err))
throw err
})
const { url } = res
userSyncService.updateProfile({
image: url,
})
}
type FeedResponseList = {
id: string

View File

@ -0,0 +1 @@
export { EditProfileModal as default } from "@/src/modules/settings/routes/EditProfile"

View File

@ -0,0 +1,96 @@
import { cn } from "@follow/utils"
import type { PropsWithChildren } from "react"
import { Pressable, Text, View } from "react-native"
import { ModalHeader } from "@/src/components/layouts/header/ModalHeader"
import { SafeModalScrollView } from "@/src/components/layouts/views/SafeModalScrollView"
import { GroupedInsetListNavigationLinkIcon } from "@/src/components/ui/grouped/GroupedList"
import { Eye2CuteReIcon } from "@/src/icons/eye_2_cute_re"
import { Grid2CuteReIcon } from "@/src/icons/grid_2_cute_re"
import { PowerIcon } from "@/src/icons/power"
import { useReadingBehavior } from "@/src/modules/onboarding/hooks/use-reading-behavior"
const SelectReadingModeScreen = () => {
const { behavior, updateSettings } = useReadingBehavior()
return (
<SafeModalScrollView className="bg-system-grouped-background">
<ModalHeader headerTitle="Select Reading Mode" />
<View className="mt-8 flex w-full gap-4">
<Card
icon={
<GroupedInsetListNavigationLinkIcon backgroundColor="#F87181">
<PowerIcon color="#fff" width={40} height={40} />
</GroupedInsetListNavigationLinkIcon>
}
isSelected={behavior === "radical"}
onPress={() => {
updateSettings("radical")
}}
>
<Text className="text-label">
Radical: Automatically mark entries as read when displayed
</Text>
</Card>
<Card
icon={
<GroupedInsetListNavigationLinkIcon backgroundColor="#34D399">
<Grid2CuteReIcon color="#fff" width={40} height={40} />
</GroupedInsetListNavigationLinkIcon>
}
isSelected={behavior === "balanced"}
onPress={() => {
updateSettings("balanced")
}}
>
<Text className="text-label">
Balanced: Automatically mark entries as read when scrolled out of view
</Text>
</Card>
<Card
icon={
<GroupedInsetListNavigationLinkIcon backgroundColor="#CBAD6D">
<Eye2CuteReIcon color="#fff" width={40} height={40} />
</GroupedInsetListNavigationLinkIcon>
}
isSelected={behavior === "conservative"}
onPress={() => {
updateSettings("conservative")
}}
>
<Text className="text-label">Conservative: Mark entries as read only when clicked</Text>
</Card>
</View>
</SafeModalScrollView>
)
}
const Card = ({
children,
onPress,
icon,
isSelected,
}: PropsWithChildren<{
icon?: React.ReactNode
onPress?: () => void
isSelected?: boolean
}>) => {
return (
<Pressable
className={cn(
"bg-secondary-system-grouped-background mx-4 flex flex-row items-center gap-2 rounded-xl p-4",
"border-2 border-transparent",
isSelected && "border-accent border-2",
)}
onPress={onPress}
>
{icon}
<View className="flex flex-1 flex-col gap-2">{children}</View>
</Pressable>
)
}
export default SelectReadingModeScreen

View File

@ -81,6 +81,17 @@ function AnimatedStack() {
},
}}
/>
<Stack.Screen
name="onboarding"
options={{
title: "Onboarding",
presentation: "transparentModal",
headerShown: false,
contentStyle: {
backgroundColor: "transparent",
},
}}
/>
</Stack>
)
}

View File

@ -0,0 +1,95 @@
import { router } from "expo-router"
import { useCallback, useState } from "react"
import { Text, TouchableOpacity, View } from "react-native"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { SheetScreen } from "react-native-sheet-transitions"
import { StepFinished } from "../modules/onboarding/step-finished"
import { StepInterests } from "../modules/onboarding/step-interests"
import { StepPreferences } from "../modules/onboarding/step-preferences"
import { StepWelcome } from "../modules/onboarding/step-welcome"
export default function Onboarding() {
const insets = useSafeAreaInsets()
const [currentStep, setCurrentStep] = useState(1)
const totalSteps = 4
const handleNext = useCallback(() => {
if (currentStep < totalSteps) {
setCurrentStep(currentStep + 1)
} else {
// Complete onboarding
router.replace("/")
}
}, [currentStep])
return (
<SheetScreen
onClose={() => {
router.back()
}}
>
<View
style={{ paddingTop: insets.top }}
className="p-safe bg-system-grouped-background flex-1 px-6"
>
<ProgressIndicator
currentStep={currentStep}
totalSteps={totalSteps}
setCurrentStep={setCurrentStep}
/>
{/* Content */}
{currentStep === 1 && <StepWelcome />}
{currentStep === 2 && <StepPreferences />}
{currentStep === 3 && <StepInterests />}
{currentStep === 4 && <StepFinished />}
{/* Navigation buttons */}
<View className="mb-6 px-6" style={{ marginBottom: insets.bottom || 24 }}>
<TouchableOpacity
onPress={handleNext}
className="bg-accent w-full items-center rounded-xl py-4"
>
<Text className="text-lg font-bold text-white">
{currentStep < totalSteps - 1
? "Next"
: currentStep === totalSteps - 1
? "Finish Setup"
: "Let's Go!"}
</Text>
</TouchableOpacity>
</View>
</View>
</SheetScreen>
)
}
export function ProgressIndicator({
currentStep,
totalSteps,
setCurrentStep,
}: {
currentStep: number
totalSteps: number
setCurrentStep: (step: number) => void
}) {
return (
<View className="mb-6 mt-4 flex flex-row justify-center gap-2">
{Array.from({ length: totalSteps }).map((_, index) => (
<TouchableOpacity
key={`step-${index}-indicator`}
onPress={() => {
setCurrentStep(index + 1)
}}
>
<View
className={`mx-1 h-2 w-10 rounded-full ${
currentStep >= index + 1 ? "bg-accent" : "bg-gray-300"
}`}
/>
</TouchableOpacity>
))}
</View>
)
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#fff" fill-opacity=".01" d="M24 0v24H0V0z"/><path stroke="#10161F" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5h9m-9 7h9m-9 7h9M7.945 3.72c-.941.725-1.754 1.53-2.475 2.475A4.225 4.225 0 0 0 4.056 4.78m0 7c.592.373 1.05.818 1.414 1.415a13.22 13.22 0 0 1 2.475-2.475m-3.89 8.06c.593.373 1.051.817 1.415 1.415a13.22 13.22 0 0 1 2.475-2.475"/></svg>

After

Width:  |  Height:  |  Size: 460 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#fff" fill-opacity=".01" d="M24 0v24H0V0z"/><path stroke="#10161F" stroke-linecap="round" stroke-width="2" d="M4 7v0c1.69 0 2.535 0 3.273.308a4 4 0 0 1 .837.473c.644.475 1.078 1.2 1.948 2.649l1.884 3.14c.87 1.45 1.304 2.174 1.948 2.649.259.19.54.35.836.473C15.466 17 16.31 17 18 17v0m0-10h-1.084c-.632 0-.948 0-1.241.044a4 4 0 0 0-2.5 1.415c-.188.229-.35.5-.675 1.041v0M4 17h1.085c.631 0 .947 0 1.24-.044a4 4 0 0 0 2.5-1.415c.188-.229.35-.5.675-1.041"/><path fill="#10161F" fill-rule="evenodd" d="M17.847 4.507c-.503-.263-1.102.127-1.15.748-.032.407-.064.994-.064 1.708 0 .748.035 1.357.068 1.764.048.58.592.914 1.09.654.33-.171.806-.438 1.376-.808.57-.37 1.013-.701 1.31-.936.448-.355.465-1.051.042-1.388a17.102 17.102 0 0 0-1.325-.95c-.55-.358-1.019-.62-1.347-.792M17.847 14.617c-.503-.263-1.102.127-1.15.748-.032.406-.064.994-.064 1.708 0 .747.035 1.357.068 1.764.048.58.592.914 1.09.654.33-.171.806-.438 1.376-.808.57-.37 1.013-.701 1.31-.936.448-.355.465-1.051.042-1.388a17.13 17.13 0 0 0-1.325-.95c-.55-.358-1.019-.62-1.347-.792" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB